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:
“ydy0615”
2026-06-18 16:32:31 +08:00
parent 4813196b0a
commit 356108e792
34 changed files with 1457 additions and 563 deletions
+9 -4
View File
@@ -24,7 +24,7 @@
| 上传块 | `upload_block` | `{{{}}}` / `{{{upload file type:...}}}` | `plugins/uploadBlockPlugin.ts` | `utils/uploadBlock.js` | | 上传块 | `upload_block` | `{{{}}}` / `{{{upload file type:...}}}` | `plugins/uploadBlockPlugin.ts` | `utils/uploadBlock.js` |
- **已验证**:当前代码完全符合"禁止嵌套、自动解析复原"的要求。修改功能块相关逻辑时需验证:1) schema 的 atom/isolating 属性不被移除;2) Remark/parseMarkdown/toMarkdown 解析链路完整。 - **已验证**:当前代码完全符合"禁止嵌套、自动解析复原"的要求。修改功能块相关逻辑时需验证: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、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。 - 历史文档中有一部分 TTS/ASR、Apple Silicon、Whisper、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。
## 先看哪里 ## 先看哪里
@@ -80,8 +80,13 @@
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。 - 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。 - OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
- /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。 - /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。
- 前端存在 /v1/export/pdf 调用点,但当前后端主路由中看不到同名端点;排查 PDF 导出问题前先确认服务端是否真正提供该接口 - **AI 开关是全局广播状态**MilkdownEditor.vue 通过 `llm-in-text:copilot-toggle` 同步主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;修 ghost text 时要同时检查这三处
- 当前 tts_asr.py 主要提供 TTS 相关能力。不要直接沿用 README 或历史修复文档里关于 ASR、Whisper、MPS/offline 的描述 - **设置项已从 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 ...` 验证容器内文件和行为,不能只看本地文件。 6. 再次用 `docker compose exec -T ...` 验证容器内文件和行为,不能只看本地文件。
- Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。 - Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。
- 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。 - 容器内访问宿主机模型服务时,不要继续使用 `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__)'"` 验证。 - **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 注册处理器并运行事件循环。 - **Redis Streams 架构**:任务队列使用 Redis Streams,支持并发控制、速率限制和熔断器。job_system.py 定义 JOB_TYPES 和队列配置,worker.py 注册处理器并运行事件循环。
- 修改 Docker 相关文件时,除了代码本身,还要同步检查: - 修改 Docker 相关文件时,除了代码本身,还要同步检查:
+1 -1
View File
@@ -103,7 +103,7 @@ RISK_WEB_SEARCH_MAX_OUTPUT_TOKENS=4096
RISK_WEB_SEARCH_TEMPERATURE=0.4 RISK_WEB_SEARCH_TEMPERATURE=0.4
RISK_COMPRESS_MAX_INPUT_CHARS=128000 RISK_COMPRESS_MAX_INPUT_CHARS=128000
RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536 RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536
RISK_OCR_MAX_INPUT_BYTES=10485760 RISK_OCR_MAX_INPUT_BYTES=104857600
# Web search providers # Web search providers
SEARXNG_BASE_URL=http://searxng:8080 SEARXNG_BASE_URL=http://searxng:8080
+10 -7
View File
@@ -4,7 +4,7 @@
## 后端职责 ## 后端职责
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。 - 对外提供补全、取消补全、OCR、文档转换和 TTS/ASR 相关接口。
- 组织 Prompt,上下文清洗,调用 Ollama 模型。 - 组织 Prompt,上下文清洗,调用 Ollama 模型。
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。** - **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
- 负责 API Key 校验、日志记录和部分启动预热逻辑。 - 负责 API Key 校验、日志记录和部分启动预热逻辑。
@@ -62,10 +62,12 @@
### /v1/ocr ### /v1/ocr
- 把 base64 图片解码成字节。 - 把 base64 媒体内容解码成字节。
- 调用 call_vlm_ocr - 支持 `media_type=image|video``mime_type`
- 图片直接调用 call_vlm_ocr。
- 视频先把整段视频送入 OCR 模型,再用 ffmpeg 抽取音轨交给 ASR,最后合并文本。
- **结果通过 job_handlers.py ocr_handler 处理。** - **结果通过 job_handlers.py ocr_handler 处理。**
- 返回识别文本原始文件名。 - 返回识别文本原始文件名,以及视频场景下的 `ocr_text` / `asr_text`
### /v1/convert ### /v1/convert
@@ -105,9 +107,9 @@
### /v1/tts-asr/* ### /v1/tts-asr/*
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。 - 通过 _register_tts_asr_routes 延迟导入并挂到主应用。
- **当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。**
- **TTS 请求通过 job_handlers.py tts_handler 处理。** - **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 端点,用于前端验证用户输入。 - **验证码路由**captcha_api.py 提供 /captcha/generate 和 /captcha/verify 端点,用于前端验证用户输入。
- **文档存储**docs_store.py 提供 MIME 类型检测、文本/二进制分类和预览提取(8MB 限制)。 - **文档存储**docs_store.py 提供 MIME 类型检测、文本/二进制分类和预览提取(8MB 限制)。
- **LLM 策略解析**llm_policy.py 按 job_type 解析模型配置(模型名、温度、最大 token、思考级别)。 - **LLM 策略解析**llm_policy.py 按 job_type 解析模型配置(模型名、温度、最大 token、思考级别)。
- **OCR 明确关闭思考**llm.py 的 `call_vlm_ocr` 对 OCR 请求显式设置 `options.think = False``temperature = 0`
- **补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。** - **补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。**
- **ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。** - **ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。**
- **main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCXPDF 桥接脚本已接入主流程。** - **`/v1/export/pdf` 不是当前主链路**DOCX/PDF 导出已经转到前端 `src/utils/richExport.js`
- **API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。** - **API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。**
- **历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。** - **历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。**
+4
View File
@@ -6,6 +6,10 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app/backend 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 COPY backend/requirements.docker.txt /tmp/requirements.docker.txt
RUN pip install --no-cache-dir -r /tmp/requirements.docker.txt RUN pip install --no-cache-dir -r /tmp/requirements.docker.txt
+52 -5
View File
@@ -14,6 +14,7 @@ import markitdown
from audit_store import get_audit_store from audit_store import get_audit_store
from llm import call_ollama, call_vlm_ocr, stream_ollama_events 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 ( from prompt import (
build_completion_prompts, build_completion_prompts,
build_pro_completion_prompts, build_pro_completion_prompts,
@@ -720,14 +721,60 @@ async def ocr_handler(
path = payload["input_path"] path = payload["input_path"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit) identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
try: 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: with open(path, "rb") as handle:
image_bytes = handle.read() media_bytes = handle.read()
text = await call_vlm_ocr(image_bytes, payload.get("language", "auto"))
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(): if is_cancelled():
raise asyncio.CancelledError() raise asyncio.CancelledError()
await emit("result", {"text": text})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=text) result = {
return {"text": text, "filename": payload.get("filename", "image.jpg")} "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: except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled") await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise raise
+8 -19
View File
@@ -77,24 +77,6 @@ class QueueFullError(JobSystemError):
self.max_queue = max_queue 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) @dataclass(frozen=True)
class QueueConfig: class QueueConfig:
job_type: str job_type: str
@@ -613,7 +595,14 @@ class RedisWorker:
group = self.manager._group_name(job_type) group = self.manager._group_name(job_type)
semaphore = self.queue_semaphores[job_type] semaphore = self.queue_semaphores[job_type]
while True: 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: if not streams:
continue continue
for _, messages in streams: for _, messages in streams:
+20 -8
View File
@@ -551,31 +551,43 @@ async def stream_ollama_events(
) )
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str: async def call_vlm_ocr(
"""OCR via VLM using OpenAI-compatible vision API (image_url content part).""" 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 = time.perf_counter()
start_dt = datetime.now() start_dt = datetime.now()
logger.info( logger.info(
'[VLM][ocr] request model=%s base_url=%s image_bytes=%d language=%s', '[VLM][ocr] request model=%s base_url=%s media_type=%s media_bytes=%d language=%s mime=%s',
VLM_MODEL, LLM_BASE_URL, len(image_bytes), language, 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 = { payload = {
'model': VLM_MODEL, 'model': VLM_MODEL,
'messages': [{ 'messages': [{
'role': 'user', 'role': 'user',
'content': [ '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', 'type': content_part_type,
'image_url': {'url': f'data:image/png;base64,{image_b64}'}, url_key: {'url': f'data:{mime_type or "application/octet-stream"};base64,{media_b64}'},
}, },
], ],
}], }],
'stream': False, 'stream': False,
'options': {
'temperature': 0,
'think': False,
},
} }
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0) http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
+24 -7
View File
@@ -106,6 +106,8 @@ class OCRRequest(BaseModel):
image: str image: str
filename: str = "image.jpg" filename: str = "image.jpg"
language: str = "auto" language: str = "auto"
media_type: str = "image"
mime_type: str | None = None
class ConvertRequest(BaseModel): class ConvertRequest(BaseModel):
@@ -281,8 +283,10 @@ def _register_handlers() -> None:
确保 Redis 重连或实例重建后处理器不会丢失""" 确保 Redis 重连或实例重建后处理器不会丢失"""
global _handlers_registered global _handlers_registered
manager = get_job_manager() manager = get_job_manager()
# 强制清空旧 handlers,避免重复注册累积 # 强制清空旧 handlers,避免重复注册累积;测试替身只暴露 register_handler。
manager.handlers.clear() handlers = getattr(manager, "handlers", None)
if handlers is not None:
handlers.clear()
manager.register_handler("completion", completion_handler) manager.register_handler("completion", completion_handler)
manager.register_handler("pro_completion", pro_completion_handler) manager.register_handler("pro_completion", pro_completion_handler)
manager.register_handler("web_search", web_search_handler) manager.register_handler("web_search", web_search_handler)
@@ -294,7 +298,8 @@ def _register_handlers() -> None:
_handlers_registered = True _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: 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: except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500) return JSONResponse({"error": str(exc)}, status_code=500)
if len(image_bytes) > config.ocr_max_input_bytes: 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") input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
try: try:
identity, payload = await _prepare_llm_payload( identity, payload = await _prepare_llm_payload(
request, request,
job_type="ocr", 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), raw_size=len(image_bytes),
token_source_text=f"{req.filename}:{len(image_bytes)}:{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}, 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) job_id = await _queue_job("ocr", payload, identity.request_id)
except RiskRejected as exc: except RiskRejected as exc:
+47
View File
@@ -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
View File
@@ -5,5 +5,5 @@ from pydantic import BaseModel
class UserPreferences(BaseModel): class UserPreferences(BaseModel):
"""用户偏好设置""" """用户偏好设置"""
language: str = "auto" language: str = "auto"
currency: str = "auto" country: str = "auto"
timezone: str = "auto" timezone: str = "auto"
+4 -4
View File
@@ -355,8 +355,8 @@ def build_completion_prompts(
if preferences: if preferences:
if preferences.language and preferences.language != "auto": if preferences.language and preferences.language != "auto":
pref_info.append(f"Preferred language: {preferences.language}") pref_info.append(f"Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto": if preferences.country and preferences.country != "auto":
pref_info.append(f"Preferred currency: {preferences.currency}") pref_info.append(f"Preferred country: {preferences.country}")
preferences_instruction = "\n".join(pref_info) preferences_instruction = "\n".join(pref_info)
if preferences_instruction: if preferences_instruction:
@@ -454,8 +454,8 @@ def build_pro_completion_prompts(
if preferences: if preferences:
if preferences.language and preferences.language != "auto": if preferences.language and preferences.language != "auto":
pref_info.append(f"Preferred language: {preferences.language}") pref_info.append(f"Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto": if preferences.country and preferences.country != "auto":
pref_info.append(f"Preferred currency: {preferences.currency}") pref_info.append(f"Preferred country: {preferences.country}")
if preferences.timezone and preferences.timezone != "auto": if preferences.timezone and preferences.timezone != "auto":
pref_info.append(f"Preferred timezone: {preferences.timezone}") pref_info.append(f"Preferred timezone: {preferences.timezone}")
+7
View File
@@ -8,3 +8,10 @@ python-multipart>=0.0.9
python-dotenv>=1.0.0 python-dotenv>=1.0.0
markitdown>=0.1.1 markitdown>=0.1.1
geoip2>=4.8.0 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
+2 -2
View File
@@ -92,7 +92,7 @@ class RiskConfig:
def load_risk_config() -> RiskConfig: def load_risk_config() -> RiskConfig:
raw_origins = _str_env( raw_origins = _str_env(
"CORS_ALLOW_ORIGINS", "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( cors_allow_origins = tuple(
origin.strip() for origin in raw_origins.split(",") if origin.strip() 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), 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_input_chars=_int_env("RISK_COMPRESS_MAX_INPUT_CHARS", 128000),
compress_max_output_tokens=_int_env("RISK_COMPRESS_MAX_OUTPUT_TOKENS", 1536), 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_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), 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), pro_input_cost_per_1k=_float_env("RISK_PRO_INPUT_COST_PER_1K", 0.003),
+1
View File
@@ -299,3 +299,4 @@ def test_call_vlm_ocr(monkeypatch):
image_part = [p for p in content_parts if p.get("type") == "image_url"] image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1 assert len(image_part) == 1
assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,") assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert captured["json"]["options"]["think"] is False
+30
View File
@@ -4,6 +4,7 @@ import importlib
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -158,6 +159,35 @@ def test_post_ocr_mocked(monkeypatch):
assert "OCR result text" in body 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(): def test_post_convert_txt_returns_markdown():
content = base64.b64encode(b"hello world").decode() content = base64.b64encode(b"hello world").decode()
with TestClient(main.app) as client: with TestClient(main.app) as client:
+4 -4
View File
@@ -35,7 +35,7 @@ def _payload():
"privacy_mode": True, "privacy_mode": True,
"user_preferences": { "user_preferences": {
"language": "zh", "language": "zh",
"currency": "CNY", "country": "CN",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
}, },
} }
@@ -43,7 +43,7 @@ def _payload():
def test_pro_queue_full_returns_429(monkeypatch): def test_pro_queue_full_returns_429(monkeypatch):
async def fake_queue_job(*args, **kwargs): 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) monkeypatch.setattr(main, "_queue_job", fake_queue_job)
with TestClient(main.app) as client: with TestClient(main.app) as client:
@@ -79,13 +79,13 @@ def test_pro_prompt_accepts_serialized_preferences():
instruction="expand", instruction="expand",
preferences={ preferences={
"language": "zh", "language": "zh",
"currency": "CNY", "country": "CN",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
}, },
) )
assert "Preferred language: zh" in user_prompt 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 assert "Preferred timezone: Asia/Shanghai" in user_prompt
+8 -2
View File
@@ -17,6 +17,7 @@ if str(BACKEND_DIR) not in sys.path:
import job_handlers # type: ignore import job_handlers # type: ignore
import job_system # type: ignore import job_system # type: ignore
import llm # type: ignore
import risk_control # type: ignore import risk_control # type: ignore
import session_store # type: ignore import session_store # type: ignore
import audit_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"]'} return {"content": '["vector database comparison", "pinecone weaviate qdrant"]'}
if tag.endswith("-webu"): if tag.endswith("-webu"):
return {"content": '["https://example.com/a", "https://example.com/b"]'} return {"content": '["https://example.com/a", "https://example.com/b"]'}
if tag.endswith("-webf"):
return {"content": "第一段\n\n第二段"}
raise AssertionError(f"unexpected tag: {tag}") 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 async def fake_searxng_search(query, *, limit): # noqa: ARG001
return [ 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, "call_ollama", fake_call_ollama)
monkeypatch.setattr(job_handlers, "_searxng_search", fake_searxng_search) monkeypatch.setattr(job_handlers, "_searxng_search", fake_searxng_search)
monkeypatch.setattr(job_handlers, "_firecrawl_scrape", fake_firecrawl_scrape) 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 TestClient(main.app) as client:
with client.stream("POST", "/v1/web-search", headers=HEADERS, json=_payload()) as resp: with client.stream("POST", "/v1/web-search", headers=HEADERS, json=_payload()) as resp:
+140 -308
View File
@@ -1,260 +1,133 @@
import asyncio import asyncio
import base64 import base64
import io
import logging import logging
import os import os
import tempfile import tempfile
import wave
from typing import Optional from typing import Optional
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com") os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
import torch
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
asyncio.get_running_loop() import numpy as np # type: ignore
except RuntimeError: except Exception as exc: # pragma: no cover
asyncio.set_event_loop(asyncio.new_event_loop()) 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: try:
from qwen_tts import Qwen3TTSModel # type: ignore from qwen_tts import Qwen3TTSModel # type: ignore
except Exception as e: # pragma: no cover except Exception as exc: # pragma: no cover
logger.debug("qwen_tts import failed (optional): %s", e) logger.debug("qwen_tts import failed: %s", exc)
Qwen3TTSModel = None # type: ignore Qwen3TTSModel = None # type: ignore
# ASR model import (MLX-based, Apple Silicon only)
try: try:
from mlx_audio.stt.models.qwen3_asr import ( # type: ignore from faster_whisper import WhisperModel # type: ignore
ForcedAlignerModel, except Exception as exc: # pragma: no cover
Qwen3ASRModel, logger.debug("faster_whisper import failed: %s", exc)
) WhisperModel = None # type: ignore
except Exception as e: # pragma: no cover
logger.debug("mlx_audio import failed (optional): %s", e)
Qwen3ASRModel = None # type: ignore
ForcedAlignerModel = None # type: ignore
try: try:
from modelscope import snapshot_download # type: ignore from modelscope import snapshot_download # type: ignore
except Exception as e: # pragma: no cover except Exception as exc: # pragma: no cover
logger.debug("modelscope import failed (optional): %s", e) logger.debug("modelscope import failed: %s", exc)
snapshot_download = None # type: ignore
meta_router = APIRouter() meta_router = APIRouter()
generation_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_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
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) _tts_model: Optional["Qwen3TTSModel"] = None
ASR_MODEL_ID_MS = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit" _asr_model: Optional["WhisperModel"] = None
ALIGN_MODEL_ID_MS = "aufklarer/Qwen3-ForcedAligner-0.6B-MLX"
def _get_device_map() -> str: def _get_device_map() -> str:
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU""" if torch is None:
return "cpu"
if torch.cuda.is_available(): if torch.cuda.is_available():
return "cuda:0" return "cuda"
try: try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps" return "mps"
except Exception as e: # noqa: ANN001 except Exception as exc: # pragma: no cover
logger.debug("MPS check failed: %s", e) logger.debug("MPS check failed: %s", exc)
return "cpu" return "cpu"
def _download_model_from_modelscope() -> Optional[str]: def _download_tts_model_from_modelscope() -> Optional[str]:
"""从 ModelScope 下载模型到本地缓存目录""" 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: try:
cache_dir = os.path.join(os.path.dirname(__file__), "models") return snapshot_download(MODEL_ID_MS, cache_dir=cache_dir, revision="master")
os.makedirs(cache_dir, exist_ok=True) except Exception as exc: # pragma: no cover
model_dir = snapshot_download( logger.warning("ModelScope TTS download failed: %s", exc)
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 None return None
async def _warmup_tts(): def _ensure_tts_model() -> "Qwen3TTSModel":
"""预热 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 模型,支持多个镜像源"""
global _tts_model global _tts_model
if _tts_model is not None: if _tts_model is not None:
return _tts_model return _tts_model
if Qwen3TTSModel is None: if np is None or torch is None or Qwen3TTSModel is None:
raise RuntimeError("qwen_tts 库未安装,无法加载 TTS 模型") raise RuntimeError("TTS 依赖未安装完整")
device_map = _get_device_map() device_map = _get_device_map()
last_err = None dtype = torch.float16 if device_map != "cpu" else torch.float32
# 策略1: 尝试从 ModelScope 下载后加载 model_path = _download_tts_model_from_modelscope()
for attempt in range(max_retries): last_error = None
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
# 策略2: 尝试从 HuggingFace 镜像加载 for candidate in [model_path, MODEL_ID_HF]:
for attempt in range(max_retries): if not candidate:
continue
try: try:
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore _tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
MODEL_ID_HF, candidate,
device_map=device_map, device_map=device_map,
dtype=torch.float16, dtype=dtype,
) )
logger.info("HuggingFace TTS 模型加载成功")
return _tts_model return _tts_model
except Exception as e: # noqa: ANN001 except Exception as exc:
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e) last_error = exc
last_err = e 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: def _ensure_asr_model() -> "WhisperModel":
"""从 ModelScope 下载并加载 ASR/ForcedAligner MLX 模型"""
global _asr_model, _align_model
if snapshot_download is None:
logger.warning("modelscope 未安装,跳过 ASR 模型加载")
return
if Qwen3ASRModel is None:
logger.warning("mlx_audio 未安装,跳过 ASR 模型加载")
return
# Download and load ASR model from ModelScope
try:
logger.info("从 ModelScope 下载 ASR 模型...")
asr_cache_dir = os.path.join(os.path.dirname(__file__), "models", "asr")
asr_model_dir = snapshot_download(ASR_MODEL_ID_MS, cache_dir=asr_cache_dir)
_load_asr_from_path(asr_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_asr_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ASR hf-mirror 加载失败,跳过 ASR: %s", e2)
# Download and load ForcedAligner model from ModelScope
try:
logger.info("从 ModelScope 下载 ForcedAligner 模型...")
align_cache_dir = os.path.join(os.path.dirname(__file__), "models", "aligner")
align_model_dir = snapshot_download(ALIGN_MODEL_ID_MS, cache_dir=align_cache_dir)
_load_align_from_path(align_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ForcedAligner ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_align_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ForcedAligner hf-mirror 加载失败,跳过: %s", e2)
def _load_asr_from_path(model_dir: str) -> None:
"""从本地路径加载 ASR MLX 模型"""
global _asr_model global _asr_model
try: if _asr_model is not None:
from mlx_audio.stt.utils import load as stt_load # type: ignore return _asr_model
if WhisperModel is None:
raise RuntimeError("faster-whisper 未安装")
model = stt_load(model_dir) device = "cuda" if _get_device_map() == "cuda" else "cpu"
_asr_model = model compute_type = ASR_COMPUTE_TYPE if device == "cpu" else "float16"
logger.info("ASR 模型加载成功 (路径: %s)", model_dir) _asr_model = WhisperModel(ASR_MODEL_ID, device=device, compute_type=compute_type)
except Exception as e: # noqa: ANN001 return _asr_model
logger.warning("ASR MLX 加载失败,尝试直接构建: %s", e)
try:
from mlx.core import load as mx_load # type: ignore
weights = mx_load(os.path.join(model_dir, "model.safetensors"))
from mlx_lm import load as lm_load # type: ignore
model = lm_load(model_dir, model_cls=Qwen3ASRModel)
_asr_model = model
except Exception as e2: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型: {e2}") from e
def _load_asr_from_hf_mirror() -> None: async def _warmup_tts():
"""从 hf-mirror 加载 ASR MLX 模型""" await asyncio.to_thread(_ensure_tts_model)
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ASR-0.6B-4bit")
_asr_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ASR MLX: {e}") from e
def _load_align_from_path(model_dir: str) -> None: async def _warmup_asr():
"""从本地路径加载 ForcedAligner MLX 模型""" await asyncio.to_thread(_ensure_asr_model)
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {model_dir}): {e}") from e
def _load_align_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ForcedAligner-0.6B-4bit")
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ForcedAligner MLX: {e}") from e
class TTSRequest(BaseModel): class TTSRequest(BaseModel):
@@ -286,43 +159,25 @@ class ModelStatus(BaseModel):
device: str device: str
def _ensure_tts_model() -> "Qwen3TTSModel": def _normalize_language(language: Optional[str]) -> Optional[str]:
"""确保 TTS 模型已加载""" if not language:
global _tts_model return None
if _tts_model is None: value = language.strip().lower()
_tts_model = _load_tts_model_with_retry() if value in {"auto", ""}:
return _tts_model return None
mapping = {
"zh-cn": "zh",
def _ensure_asr_model(): "zh-hans": "zh",
"""确保 ASR 模型已加载(懒加载)""" "zh-tw": "zh",
global _asr_model "en-us": "en",
if _asr_model is None: "ja-jp": "ja",
try: "ko-kr": "ko",
from mlx_audio.stt.utils import load as stt_load # type: ignore }
return mapping.get(value, value.split("-")[0])
_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
@meta_router.get("/status", response_model=ModelStatus) @meta_router.get("/status", response_model=ModelStatus)
async def get_status(): async def get_status():
"""获取模型状态"""
return ModelStatus( return ModelStatus(
tts_loaded=_tts_model is not None, tts_loaded=_tts_model is not None,
asr_loaded=_asr_model is not None, asr_loaded=_asr_model is not None,
@@ -332,11 +187,10 @@ async def get_status():
@meta_router.get("/config") @meta_router.get("/config")
async def get_config(): async def get_config():
"""获取配置信息"""
return { return {
"model": { "model": {
"tts": MODEL_ID_MS, "tts": MODEL_ID_MS,
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None, "asr": ASR_MODEL_ID,
}, },
"device": _get_device_map(), "device": _get_device_map(),
"status": { "status": {
@@ -348,15 +202,11 @@ async def get_config():
@meta_router.post("/warmup") @meta_router.post("/warmup")
async def warmup_models(): async def warmup_models():
"""手动触发模型预热"""
await _warmup_tts() await _warmup_tts()
await _warmup_asr()
if Qwen3ASRModel is not None:
await _warmup_asr()
return { return {
"tts_warmup": _tts_model is not None, "tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False, "asr_warmup": _asr_model is not None,
"device": _get_device_map(), "device": _get_device_map(),
} }
@@ -367,26 +217,30 @@ async def generate_tts_response(
speaker: str = "Vivian", speaker: str = "Vivian",
output_format: str = "wav", output_format: str = "wav",
) -> TTSResponse: ) -> TTSResponse:
del speaker # current model path does not expose multi-speaker routing del speaker
del output_format # current implementation always returns wav del output_format
try: if np is None:
model = _ensure_tts_model() raise HTTPException(status_code=501, detail="numpy 未安装,TTS 功能不可用")
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=str(e))
try: 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, text=text,
language="Chinese", language="Chinese",
instruct=instruct or "", instruct=instruct or "",
) )
except Exception as e: # noqa: ANN001 except Exception as exc:
logger.exception("TTS 推理失败") logger.exception("TTS inference failed")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}") raise HTTPException(status_code=500, detail=f"TTS 推理失败: {exc}")
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
if hasattr(wav_data, 'numpy'): # type: ignore if hasattr(wav_data, "cpu"):
wav_data = wav_data.cpu().numpy() # type: ignore wav_data = wav_data.cpu().numpy()
wav_data = np.asarray(wav_data, dtype=np.float32) wav_data = np.asarray(wav_data, dtype=np.float32)
tmp_path = None tmp_path = None
@@ -394,81 +248,66 @@ async def generate_tts_response(
import soundfile as sf # type: ignore import soundfile as sf # type: ignore
fd, tmp_path = tempfile.mkstemp(suffix=".wav") fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd) # type: ignore os.close(fd)
sf.write(tmp_path, wav_data, sr) sf.write(tmp_path, wav_data, sample_rate)
with open(tmp_path, "rb") as f: # noqa: SIM115 with open(tmp_path, "rb") as handle:
audio_bytes = f.read() audio_bytes = handle.read()
except Exception as e: # noqa: ANN001 except Exception as exc:
logger.exception("音频编码失败") logger.exception("TTS audio encode failed")
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}") raise HTTPException(status_code=500, detail=f"音频编码失败: {exc}")
finally: finally:
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201 if tmp_path and os.path.exists(tmp_path):
try: os.unlink(tmp_path)
os.unlink(tmp_path)
except Exception:
pass
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0 duration_ms = int(len(wav_data) / sample_rate * 1000) if sample_rate > 0 else 0
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8") return TTSResponse(
return TTSResponse(audio_base64=audio_base64, format="wav", duration_ms=duration_ms) 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: async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
if Qwen3ASRModel is None: if not audio_bytes:
raise HTTPException(status_code=501, detail="mlx_audio 未安装,ASR 功能不可用") raise HTTPException(status_code=400, detail="音频内容为空")
try: try:
model = _ensure_asr_model() model = _ensure_asr_model()
except Exception as e: # noqa: ANN001 except Exception as exc:
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}") raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {exc}")
normalized_language = _normalize_language(language)
tmp_path = None
try: try:
wav_buffer = io.BytesIO(audio_bytes) fd, tmp_path = tempfile.mkstemp(suffix=".wav")
with wave.open(wav_buffer, 'rb') as wf: # noqa: SIM115 os.close(fd)
n_channels = wf.getnchannels() with open(tmp_path, "wb") as handle:
sampwidth = wf.getsampwidth() handle.write(audio_bytes)
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames) segments, info = await asyncio.to_thread(
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32) model.transcribe,
tmp_path,
if n_channels > 1: language=normalized_language,
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1) vad_filter=True,
beam_size=5,
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,
) )
text = "".join(segment.text for segment in segments).strip()
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result) if not text:
detected_lang = getattr(result, 'language', language or "zh-CN") raise RuntimeError("ASR 返回结果为空")
if isinstance(detected_lang, list) and len(detected_lang) > 0: detected_language = getattr(info, "language", normalized_language or "unknown")
detected_lang = detected_lang[0] return ASRResponse(text=text, language=str(detected_language))
return ASRResponse(text=recognized_text, language=str(detected_lang))
except HTTPException: except HTTPException:
raise raise
except Exception as e: # noqa: ANN001 except Exception as exc:
logger.exception("ASR 推理失败") logger.exception("ASR inference failed")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}") 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) @generation_router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest): async def tts_endpoint(req: TTSRequest):
"""TTS 文字转语音端点"""
return await generate_tts_response( return await generate_tts_response(
text=req.text, text=req.text,
instruct=req.instruct or "", instruct=req.instruct or "",
@@ -479,18 +318,11 @@ async def tts_endpoint(req: TTSRequest):
@generation_router.post("/asr", response_model=ASRResponse) @generation_router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest): async def asr_endpoint(req: ASRRequest):
"""语音识别端点(非流式)"""
audio_bytes = base64.b64decode(req.audio_base64) audio_bytes = base64.b64decode(req.audio_base64)
return await generate_asr_response(audio_bytes, req.language if req.language else None) 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): def register_tts_asr_routes(app, include_generation_routes: bool = True):
"""注册 TTS/ASR 路由到 FastAPI 应用"""
app.include_router(meta_router, prefix="/v1/tts-asr") app.include_router(meta_router, prefix="/v1/tts-asr")
if include_generation_routes: if include_generation_routes:
app.include_router(generation_router, prefix="/v1/tts-asr") app.include_router(generation_router, prefix="/v1/tts-asr")
router = APIRouter()
router.include_router(meta_router)
router.include_router(generation_router)
+7 -4
View File
@@ -52,7 +52,7 @@
- 上传图片和文档 - 上传图片和文档
- 触发 OCR(通过 **OCRImageWrapper.vue** - 触发 OCR(通过 **OCRImageWrapper.vue**
- 导入导出 Markdown - 导入导出 Markdown
- 导出 DOCX 和 PDF - 导出 DOCX 和 PDF(纯前端,从展开后的编辑器 HTML 导出)
- AI 开关 - AI 开关
- 32 KB 大小限制 - 32 KB 大小限制
- TTS 菜单和播放器 - TTS 菜单和播放器
@@ -68,7 +68,7 @@
- debounceMs - debounceMs
- privacyMode - privacyMode
- language - language
- currency - country
- backgroundType - backgroundType
- backgroundImage - backgroundImage
- backgroundOpacity - backgroundOpacity
@@ -85,7 +85,7 @@
- 生成 request_id - 生成 request_id
- 绑定 AbortSignal - 绑定 AbortSignal
- 触发中止时的 cancel 请求 - 触发中止时的 cancel 请求
- 读取 settings store 中的 thinking、privacy、language、currency、timezone 信息 - 读取 settings store 中的 thinking、privacy、language、country、timezone 信息
- 向后端发 POST 并读取 JSON - 向后端发 POST 并读取 JSON
## 当前真实约定 ## 当前真实约定
@@ -99,9 +99,12 @@
## 容易踩坑的点 ## 容易踩坑的点
- 文档超过 32 KB 时,AI 补全会被禁用。 - 文档超过 32 KB 时,AI 补全会被禁用。
- AI 开关需要同时作用到主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;当前通过 `llm-in-text:copilot-toggle` 广播同步。
- 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。 - 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。
- OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。 - 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](不区分大小写)。** - **Web Search 块使用 ```llm-websearch fenced code 语法,触发词为 [WEBSEARCH](不区分大小写)。**
- **验证码组件依赖 vue3-captcha 库,刷新和验证逻辑需保持与后端 /captcha/generate 和 /captcha/verify 接口同步。** - **验证码组件依赖 vue3-captcha 库,刷新和验证逻辑需保持与后端 /captcha/generate 和 /captcha/verify 接口同步。**
- 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。 - 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。
+25 -2
View File
@@ -56,6 +56,7 @@ import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView
import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js' import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js' import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
const props = defineProps({ const props = defineProps({
docType: { type: String, default: 'txt' }, docType: { type: String, default: 'txt' },
docName: { type: String, default: 'document.txt' }, docName: { type: String, default: 'document.txt' },
@@ -77,6 +78,7 @@ let crepe = null
let syncTimer = null let syncTimer = null
let syncingExternal = false let syncingExternal = false
let compressPoller = null let compressPoller = null
let copilotToggleHandler = null
const handleCompress = () => { const handleCompress = () => {
if (compressState.value !== 'idle') return if (compressState.value !== 'idle') return
@@ -233,8 +235,26 @@ onMounted(async () => {
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) 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(() => { onUnmounted(() => {
@@ -246,6 +266,10 @@ onUnmounted(() => {
compressPoller.stop() compressPoller.stop()
compressPoller = null compressPoller = null
} }
if (copilotToggleHandler) {
window.removeEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
copilotToggleHandler = null
}
if (crepe) { if (crepe) {
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
@@ -444,4 +468,3 @@ onUnmounted(() => {
display: none !important; display: none !important;
} }
</style> </style>
+223
View File
@@ -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
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="editor-container"> <div class="editor-container" :style="editorTypographyStyle">
<div ref="root" class="milkdown-editor"></div> <div ref="root" class="milkdown-editor"></div>
<div class="history-buttons"> <div class="history-buttons">
@@ -86,8 +86,8 @@
</button> </button>
<div v-if="showExportDropdown" class="export-dropdown"> <div v-if="showExportDropdown" class="export-dropdown">
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button> <button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
<button type="button" class="disabled-export" :title="t('exportDisabledHint')" @click="showExportDisabled">{{ t('exportDocx') }}</button> <button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
<button type="button" class="disabled-export" :title="t('exportDisabledHint')" @click="showExportDisabled">{{ t('exportPdf') }}</button> <button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
</div> </div>
</div> </div>
@@ -320,13 +320,14 @@ import { fetchProSuggestionStream, fetchSuggestion, fetchTTS, fetchWebSearchStre
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useTemplatesStore } from '../stores/templates' import { useTemplatesStore } from '../stores/templates'
import { useTheme } from '../composables/useTheme.js' 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 TTSMenu from './TTSMenu.vue'
import TTSPlayer from './TTSPlayer.vue' import TTSPlayer from './TTSPlayer.vue'
import { convertFileToMarkdown, convertAudioToText } from '../utils/convert.js' import { convertFileToMarkdown, convertAudioToText, convertVideoToText } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.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 { 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 { isUploadBlockTypeAllowed } from '../utils/uploadBlock.js'
import { WEB_SEARCH_NODE_TYPE } from '../utils/webSearch.js' import { WEB_SEARCH_NODE_TYPE } from '../utils/webSearch.js'
@@ -336,6 +337,7 @@ const templateStore = useTemplatesStore()
const { isDark } = useTheme() const { isDark } = useTheme()
const t = (key) => settings.t[key] const t = (key) => settings.t[key]
const initialMarkdown = computed(() => settings.initialMarkdown) const initialMarkdown = computed(() => settings.initialMarkdown)
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
const root = ref(null) const root = ref(null)
const uploadInputRef = ref(null) const uploadInputRef = ref(null)
@@ -416,9 +418,8 @@ const CONVERT_MIME_TYPES = new Set([
'application/pdf', 'application/pdf',
]) ])
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska']) 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_BATCH = 10
const MAX_UPLOAD_FILE_SIZE = 50 * 1024 * 1024 const MAX_UPLOAD_FILE_SIZE = 100 * 1024 * 1024
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value) let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '') 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 downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const anchor = document.createElement('a') const anchor = document.createElement('a')
@@ -744,9 +717,15 @@ const clearCurrentSuggestion = (view) => {
const clearCurrentGhost = () => { const clearCurrentGhost = () => {
if (!crepe) return if (!crepe) return
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
clearGhostSuggestion(view) clearGhostSuggestion(view)
}) })
}
const 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) => { const updateHistoryState = (view) => {
@@ -1059,42 +1038,59 @@ const consumeSseResult = async (res) => {
} }
const performOCR = async (file, cacheKey, imageHash = '') => { const performOCR = async (file, cacheKey, imageHash = '') => {
if (!aiEnabled.value) return if (!aiEnabled.value) return ''
const reader = new FileReader() if (imageHash) {
reader.onload = async () => { setOcrState(imageHash, OcrStatus.LOADING)
const dataUrl = typeof reader.result === 'string' ? reader.result : '' }
const splitIndex = dataUrl.indexOf(',')
if (splitIndex === -1) return const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader()
const base64 = dataUrl.slice(splitIndex + 1) reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
try { reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
const headers = { reader.readAsDataURL(file)
'Content-Type': 'application/json', })
}
const res = await fetch(OCR_URL, { const splitIndex = dataUrl.indexOf(',')
method: 'POST', if (splitIndex === -1) {
headers, throw new Error('图片编码失败')
credentials: 'include', }
body: JSON.stringify({
image: base64, const base64 = dataUrl.slice(splitIndex + 1)
filename: file.name, const headers = {
language: 'auto' 'Content-Type': 'application/json',
}) }
})
const data = await consumeSseResult(res) try {
if (data.text) { const res = await fetch(OCR_URL, {
setOcrCache(cacheKey, data.text) method: 'POST',
setOcrCache(file.name, data.text) headers,
if (imageHash) { credentials: 'include',
setOcrByHash(imageHash, data.text) body: JSON.stringify({
} image: base64,
} filename: file.name,
} catch { language: 'auto',
// OCR error, ignore 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) => { const prepareImageFile = async (file) => {
@@ -1116,10 +1112,11 @@ const prepareImageFile = async (file) => {
const imageHash = await calculateImageHash(imageBytes) const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash) const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) { if (!existingOcr) {
performOCR(file, objectUrl, imageHash) await performOCR(file, objectUrl, imageHash)
} else { } else {
setOcrCache(objectUrl, existingOcr) setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr) setOcrCache(file.name, existingOcr)
setOcrState(imageHash, OcrStatus.SUCCESS, existingOcr)
} }
return objectUrl return objectUrl
@@ -1183,12 +1180,14 @@ const validateUploadFiles = (files, options = {}) => {
return true return true
} }
const parseDocFilesToBlocks = async (docFiles) => { const parseDocFilesToBlocks = async (docFiles, progressOptions = {}) => {
if (docFiles.length === 0) { if (docFiles.length === 0) {
return { blocksToInsert: [], errors: [] } 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 results = []
const errors = [] const errors = []
@@ -1203,6 +1202,8 @@ const parseDocFilesToBlocks = async (docFiles) => {
content = await convertFileToMarkdown(file) content = await convertFileToMarkdown(file)
} else if (isAudioFile(file)) { } else if (isAudioFile(file)) {
content = await convertAudioToText(file) content = await convertAudioToText(file)
} else if (isVideoFile(file)) {
content = await convertVideoToText(file)
} else { } else {
throw new Error('不支持的文件类型') throw new Error('不支持的文件类型')
} }
@@ -1222,7 +1223,7 @@ const parseDocFilesToBlocks = async (docFiles) => {
const settled = await Promise.allSettled(parsePromises) const settled = await Promise.allSettled(parsePromises)
settled.forEach((result, idx) => { 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') { if (result.status === 'fulfilled') {
results.push(result.value) results.push(result.value)
} else { } else {
@@ -1230,9 +1231,6 @@ const parseDocFilesToBlocks = async (docFiles) => {
errors.push({ filename: docFiles[idx].name, message }) errors.push({ filename: docFiles[idx].name, message })
} }
}) })
uploadProgress.value = null
results.sort((a, b) => a.index - b.index) results.sort((a, b) => a.index - b.index)
return { return {
@@ -1258,16 +1256,28 @@ const processUploadedFiles = async (files, options = {}) => {
const docFiles = normalizedFiles.filter((file) => !isImageFile(file)) const docFiles = normalizedFiles.filter((file) => !isImageFile(file))
const replaceRange = options.replaceRange || null const replaceRange = options.replaceRange || null
let rangeConsumed = false let rangeConsumed = false
let progressIndex = 0
const totalCount = normalizedFiles.length
for (const file of imageFiles) { for (const file of imageFiles) {
const objectUrl = await prepareImageFile(file) uploadProgress.value = { current: progressIndex + 1, total: totalCount, filename: `${file.name} · OCR` }
if (!objectUrl) continue try {
insertImageAtCursor(objectUrl, !rangeConsumed ? replaceRange : null) const objectUrl = await prepareImageFile(file)
rangeConsumed = rangeConsumed || Boolean(replaceRange) 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) { if (docFiles.length > 0) {
const { blocksToInsert, errors } = await parseDocFilesToBlocks(docFiles) const { blocksToInsert, errors } = await parseDocFilesToBlocks(docFiles, {
offset: progressIndex,
total: totalCount,
})
if (blocksToInsert.length > 0) { if (blocksToInsert.length > 0) {
insertMultipleDocBlocks(blocksToInsert, !rangeConsumed ? replaceRange : null) insertMultipleDocBlocks(blocksToInsert, !rangeConsumed ? replaceRange : null)
rangeConsumed = rangeConsumed || Boolean(replaceRange) rangeConsumed = rangeConsumed || Boolean(replaceRange)
@@ -1278,8 +1288,11 @@ const processUploadedFiles = async (files, options = {}) => {
const errorMsgs = errors.map((item) => `${item.filename}: ${item.message}`).join('\n') const errorMsgs = errors.map((item) => `${item.filename}: ${item.message}`).join('\n')
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`) alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
} }
progressIndex += docFiles.length
} }
uploadProgress.value = null
return true return true
} }
@@ -1478,6 +1491,7 @@ crepe = new Crepe({
} }
document.addEventListener('mousedown', ttsClickOutsideHandler) document.addEventListener('mousedown', ttsClickOutsideHandler)
}) })
broadcastCopilotEnabled(aiEnabled.value)
scheduleMarkdownSync() scheduleMarkdownSync()
}) })
@@ -1493,15 +1507,20 @@ const exportMarkdown = async () => {
} }
} }
const showExportDisabled = () => { const resolveEditorExportElement = () => {
alert(t('exportDisabledHint') || 'DOCX/PDF 导出暂不可用。') 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 () => { const exportDocx = async () => {
try { try {
console.log('Exporting DOCX...') console.log('Exporting DOCX from expanded editor HTML...')
const markdown = await getExportMarkdown() const blob = await exportEditorToDocxBlob(resolveEditorExportElement())
const blob = await buildDocxBlob(markdown)
const exportName = createExportName() const exportName = createExportName()
downloadBlob(blob, `${exportName}.docx`) downloadBlob(blob, `${exportName}.docx`)
console.log('DOCX export completed') console.log('DOCX export completed')
@@ -1513,28 +1532,10 @@ const exportDocx = async () => {
const exportPdf = async () => { const exportPdf = async () => {
try { try {
console.log('Exporting PDF via DOCX...') console.log('Exporting PDF from expanded editor HTML...')
const markdown = await getExportMarkdown()
const docxBlob = await buildDocxBlob(markdown)
const exportName = createExportName() const exportName = createExportName()
const formData = new FormData() await exportEditorToPdf(resolveEditorExportElement(), `${exportName}.pdf`)
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`)
console.log('PDF export completed successfully') console.log('PDF export completed successfully')
alert('PDF导出成功!')
} catch (error) { } catch (error) {
console.error('PDF export failed:', error) console.error('PDF export failed:', error)
alert(`PDF导出失败: ${error.message}`) alert(`PDF导出失败: ${error.message}`)
@@ -1592,9 +1593,12 @@ const toggleAI = async () => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value) setCopilotEnabled(view, aiEnabled.value)
if (!aiEnabled.value) { if (!aiEnabled.value) {
clearCurrentSuggestion(view) interruptCopilot(view)
clearGhostSuggestion(view)
} }
}) })
broadcastCopilotEnabled(aiEnabled.value)
} }
const toggleExportDropdown = () => { const toggleExportDropdown = () => {
@@ -1839,7 +1843,7 @@ for (const url of Array.from(objectUrls)) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: var(--panel-shadow); box-shadow: var(--panel-shadow);
opacity: 0.5; opacity: 0.8;
} }
.action-btn:hover { .action-btn:hover {
@@ -2051,16 +2055,6 @@ for (const url of Array.from(objectUrls)) {
background: var(--crepe-color-hover); background: var(--crepe-color-hover);
} }
.export-dropdown button.disabled-export {
color: var(--muted-text);
cursor: not-allowed;
opacity: 0.58;
}
.export-dropdown button.disabled-export:hover {
background: transparent;
}
.template-dropdown { .template-dropdown {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
@@ -2075,6 +2069,7 @@ for (const url of Array.from(objectUrls)) {
z-index: 100000; z-index: 100000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
opacity: 1 !important;
} }
.template-dropdown-section { .template-dropdown-section {
+14 -13
View File
@@ -335,21 +335,22 @@ const validateCaptcha = () => {
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{ t('currency') }}</label> <label>{{ t('country') }}</label>
<select v-model="store.currency" class="select-input" :disabled="store.privacyMode"> <select v-model="store.country" class="select-input" :disabled="store.privacyMode">
<option value="auto">{{ t('auto') }}</option> <option value="auto">{{ t('auto') }}</option>
<option value="CNY">CNY (¥)</option> <option value="CN">中国</option>
<option value="USD">USD ($)</option> <option value="US">United States</option>
<option value="EUR">EUR ()</option> <option value="JP">日本</option>
<option value="JPY">JPY (¥)</option> <option value="KR">대한민국</option>
<option value="KRW">KRW ()</option> <option value="GB">United Kingdom</option>
<option value="GBP">GBP (£)</option> <option value="DE">Deutschland</option>
<option value="AUD">AUD ($)</option> <option value="FR">France</option>
<option value="CAD">CAD ($)</option> <option value="CA">Canada</option>
<option value="AU">Australia</option>
</select> </select>
</div> </div>
</section> </section>
<!-- Security & Captcha Section --> <!-- Security & Captcha Section -->
<section class="settings-section" v-if="showCaptcha"> <section class="settings-section" v-if="showCaptcha">
+25 -1
View File
@@ -95,6 +95,7 @@ import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView
import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js' import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js' import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
const COPILOT_TOGGLE_EVENT = 'llm-in-text:copilot-toggle'
const props = defineProps({ const props = defineProps({
stage: { type: String, default: 'idle' }, stage: { type: String, default: 'idle' },
progressText: { type: String, default: '' }, progressText: { type: String, default: '' },
@@ -119,6 +120,7 @@ let crepe = null
let syncTimer = null let syncTimer = null
let syncingExternal = false let syncingExternal = false
let compressPoller = null let compressPoller = null
let copilotToggleHandler = null
const ensureEditor = async () => { const ensureEditor = async () => {
if (crepe || !editorRoot.value || !hasContent.value) return if (crepe || !editorRoot.value || !hasContent.value) return
@@ -166,8 +168,26 @@ const ensureEditor = async () => {
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) 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())) const hasContent = computed(() => Boolean((props.content || '').trim()))
@@ -277,6 +297,10 @@ onUnmounted(() => {
compressPoller.stop() compressPoller.stop()
compressPoller = null compressPoller = null
} }
if (copilotToggleHandler) {
window.removeEventListener(COPILOT_TOGGLE_EVENT, copilotToggleHandler)
copilotToggleHandler = null
}
if (crepe) { if (crepe) {
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
-7
View File
@@ -177,13 +177,6 @@ class DocBlockNodeView implements NodeView {
this.view.focus() this.view.focus()
} }
destroy(hasNewHeader: boolean, hasNewFooter: boolean) {
if (this.app) {
this.app.unmount()
this.app = null
}
}
compressWithNewContent(compressedContent: string) { compressWithNewContent(compressedContent: string) {
const pos = this.getPosValue() const pos = this.getPosValue()
if (pos === undefined) return if (pos === undefined) return
+74
View File
@@ -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
-16
View File
@@ -412,21 +412,6 @@ class WebSearchBlockNodeView implements NodeView {
return return
} }
this.setStage(event, String(data?.message || '')) 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) => { onDelta: (data) => {
if (this.destroyed || this.requestSeq !== requestSeq) return if (this.destroyed || this.requestSeq !== requestSeq) return
@@ -685,4 +670,3 @@ export const webSearchBlockInputPlugin = $prose(() => {
}, },
}) })
}) })
+15 -8
View File
@@ -13,11 +13,13 @@ export const useSettingsStore = defineStore('settings', () => {
const proThinking = ref('medium') // 'low' | 'medium' | 'high' const proThinking = ref('medium') // 'low' | 'medium' | 'high'
const privacyMode = ref(true) const privacyMode = ref(true)
const language = ref('auto') const language = ref('auto')
const currency = ref('auto') const country = ref('auto')
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image' const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
const backgroundImage = ref('') const backgroundImage = ref('')
const backgroundOpacity = ref(0.2) // 0.05 - 0.50 const backgroundOpacity = ref(0.2) // 0.05 - 0.50
const ttsInstruct = ref('') const ttsInstruct = ref('')
const fontFamily = ref('system') // 'system' | 'serif' | 'monospace'
const fontSize = ref(16) // 12 - 32
// --- Computed getters (derived from reactive state) --- // --- Computed getters (derived from reactive state) ---
const uiLanguage = computed(() => { const uiLanguage = computed(() => {
@@ -43,12 +45,15 @@ export const useSettingsStore = defineStore('settings', () => {
if (data.proThinking) proThinking.value = data.proThinking if (data.proThinking) proThinking.value = data.proThinking
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.language) language.value = data.language if (data.language) language.value = data.language
if (data.currency) currency.value = data.currency if (data.country) country.value = data.country
else if (data.currency) country.value = data.currency
if (data.backgroundType === 'color') backgroundType.value = 'default' if (data.backgroundType === 'color') backgroundType.value = 'default'
else if (data.backgroundType) backgroundType.value = data.backgroundType else if (data.backgroundType) backgroundType.value = data.backgroundType
if (data.backgroundImage) backgroundImage.value = data.backgroundImage if (data.backgroundImage) backgroundImage.value = data.backgroundImage
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct 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 */ } } catch { /* use defaults */ }
} }
@@ -58,9 +63,11 @@ export const useSettingsStore = defineStore('settings', () => {
theme: theme.value, modelThinking: modelThinking.value, theme: theme.value, modelThinking: modelThinking.value,
debounceMs: debounceMs.value, proThinking: proThinking.value, debounceMs: debounceMs.value, proThinking: proThinking.value,
privacyMode: privacyMode.value, language: language.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, backgroundImage: backgroundImage.value, backgroundOpacity: backgroundOpacity.value,
ttsInstruct: ttsInstruct.value, ttsInstruct: ttsInstruct.value,
fontFamily: fontFamily.value,
fontSize: fontSize.value,
})) }))
} catch { /* silently fail */ } } catch { /* silently fail */ }
} }
@@ -68,17 +75,17 @@ export const useSettingsStore = defineStore('settings', () => {
const resetSettings = () => { const resetSettings = () => {
theme.value = 'system'; modelThinking.value = 'low'; debounceMs.value = 1000 theme.value = 'system'; modelThinking.value = 'low'; debounceMs.value = 1000
proThinking.value = 'medium'; privacyMode.value = false; language.value = 'auto' proThinking.value = 'medium'; privacyMode.value = false; language.value = 'auto'
currency.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = '' country.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = ''
backgroundOpacity.value = 0.2; ttsInstruct.value = ''; saveSettings() backgroundOpacity.value = 0.2; ttsInstruct.value = ''; fontFamily.value = 'system'; fontSize.value = 16; saveSettings()
} }
// Watch all reactive refs and auto-save on change // Watch all reactive refs and auto-save on change
watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, currency watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, country
, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct], saveSettings) , backgroundType, backgroundImage, backgroundOpacity, ttsInstruct, fontFamily, fontSize], saveSettings)
loadSettings() // Initialize from localStorage loadSettings() // Initialize from localStorage
return { theme, modelThinking, debounceMs, proThinking, privacyMode, language return { theme, modelThinking, debounceMs, proThinking, privacyMode, language
, currency, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct , country, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct, fontFamily, fontSize
, detectedTimezone, uiLanguage, t, initialMarkdown, resetSettings } , detectedTimezone, uiLanguage, t, initialMarkdown, resetSettings }
}) })
+3 -3
View File
@@ -76,7 +76,7 @@ function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
privacy_mode: settings.privacyMode, privacy_mode: settings.privacyMode,
user_preferences: { user_preferences: {
language: settings.language, language: settings.language,
currency: settings.currency, country: settings.country,
timezone: settings.detectedTimezone, timezone: settings.detectedTimezone,
}, },
...extra, ...extra,
@@ -252,7 +252,7 @@ export async function fetchProSuggestionStream(payload, apiUrl = PRO_URL) {
privacy_mode: settings.privacyMode, privacy_mode: settings.privacyMode,
user_preferences: { user_preferences: {
language: settings.language, language: settings.language,
currency: settings.currency, country: settings.country,
timezone: settings.detectedTimezone, timezone: settings.detectedTimezone,
}, },
}, },
@@ -304,7 +304,7 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) {
privacy_mode: settings.privacyMode, privacy_mode: settings.privacyMode,
user_preferences: { user_preferences: {
language: settings.language, language: settings.language,
currency: settings.currency, country: settings.country,
timezone: settings.detectedTimezone, timezone: settings.detectedTimezone,
}, },
}, },
+26 -1
View File
@@ -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' import { parseSseEvent } from './sse.js'
@@ -86,6 +86,31 @@ export async function convertFileToMarkdown(file) {
return data.markdown 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 * Encode AudioBuffer as WAV (16kHz mono, 16-bit PCM) and return base64 string
*/ */
+11 -1
View File
@@ -18,6 +18,7 @@ function clipDocContext(content = '', limit = 0) {
} }
const AUDIO_EXT_RE = /\.(wav|mp3|m4a|ogg|flac)$/i 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 = '') { export function normalizeDocType(value = '') {
const lower = String(value || '').trim().toLowerCase() const lower = String(value || '').trim().toLowerCase()
@@ -61,6 +62,13 @@ export function isAudioFile(file) {
return AUDIO_EXT_RE.test(name) || type.startsWith('audio/') 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) { export function isSupportedDocFile(file) {
if (!file) return false if (!file) return false
const name = String(file.name || '').toLowerCase() const name = String(file.name || '').toLowerCase()
@@ -75,6 +83,7 @@ export function isSupportedDocFile(file) {
name.endsWith('.pptx') || name.endsWith('.pptx') ||
name.endsWith('.pdf') || name.endsWith('.pdf') ||
AUDIO_EXT_RE.test(name) || AUDIO_EXT_RE.test(name) ||
VIDEO_EXT_RE.test(name) ||
type === 'text/plain' || type === 'text/plain' ||
type === 'application/json' || type === 'application/json' ||
type === 'text/yaml' || type === 'text/yaml' ||
@@ -83,7 +92,8 @@ export function isSupportedDocFile(file) {
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
type === 'application/pdf' || type === 'application/pdf' ||
type.startsWith('audio/') type.startsWith('audio/') ||
type.startsWith('video/')
) )
} }
+12 -12
View File
@@ -33,7 +33,7 @@ export const translations = {
privacyDesc: 'Prevent sending IP and preferences to the AI', privacyDesc: 'Prevent sending IP and preferences to the AI',
language: 'Language', language: 'Language',
auto: 'Auto Detect', auto: 'Auto Detect',
currency: 'Currency', country: 'Country',
about: 'About Us', about: 'About Us',
importMd: 'Import Markdown', importMd: 'Import Markdown',
exportMd: 'Export Markdown', exportMd: 'Export Markdown',
@@ -52,7 +52,7 @@ export const translations = {
uploadFileError: 'File upload failed.', uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.', uploadConvertError: 'File conversion failed.',
uploadBatchLimit: 'Maximum 10 files at once', uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit', uploadSizeLimit: 'File exceeds 100MB limit',
uploading: 'Uploading files...', uploading: 'Uploading files...',
enableAI: 'Enable AI', enableAI: 'Enable AI',
disableAI: 'Disable AI', disableAI: 'Disable AI',
@@ -160,7 +160,7 @@ export const translations = {
privacyDesc: '不向 AI 发送 IP 地址和偏好设置', privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
language: '语言', language: '语言',
auto: '自动检测', auto: '自动检测',
currency: '货币', country: '国家',
about: '关于我们', about: '关于我们',
importMd: '导入 Markdown', importMd: '导入 Markdown',
exportMd: '导出 Markdown', exportMd: '导出 Markdown',
@@ -179,7 +179,7 @@ export const translations = {
uploadFileError: '文件上传失败', uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败', uploadConvertError: '文件转换失败',
uploadBatchLimit: '一次最多上传10个文件', uploadBatchLimit: '一次最多上传10个文件',
uploadSizeLimit: '文件超过50MB限制', uploadSizeLimit: '文件超过100MB限制',
uploading: '正在上传文件...', uploading: '正在上传文件...',
enableAI: '启用 AI', enableAI: '启用 AI',
disableAI: '禁用 AI', disableAI: '禁用 AI',
@@ -287,7 +287,7 @@ export const translations = {
privacyDesc: 'AIにIPアドレスと設定を送信しない', privacyDesc: 'AIにIPアドレスと設定を送信しない',
language: '言語', language: '言語',
auto: '自動検出', auto: '自動検出',
currency: '通貨', country: '',
about: '私たちについて', about: '私たちについて',
template: 'テンプレート', template: 'テンプレート',
presetTemplates: 'プリセットテンプレート', presetTemplates: 'プリセットテンプレート',
@@ -328,7 +328,7 @@ export const translations = {
uploadFileError: 'アップロードに失敗しました。', uploadFileError: 'アップロードに失敗しました。',
uploadConvertError: '変換に失敗しました。', uploadConvertError: '変換に失敗しました。',
uploadBatchLimit: '一度にアップロードできるのは10ファイルまでです。', uploadBatchLimit: '一度にアップロードできるのは10ファイルまでです。',
uploadSizeLimit: '50MBの制限を超えています。', uploadSizeLimit: '100MBの制限を超えています。',
uploading: 'アップロード中...', uploading: 'アップロード中...',
enableAI: 'AIを有効化', enableAI: 'AIを有効化',
disableAI: 'AIを無効化', disableAI: 'AIを無効化',
@@ -408,7 +408,7 @@ export const translations = {
privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함', privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함',
language: '언어', language: '언어',
auto: '자동 감지', auto: '자동 감지',
currency: '통화', country: '국가',
about: '회사 소개', about: '회사 소개',
template: '템플릿', template: '템플릿',
presetTemplates: '사전 정의된 템플릿', presetTemplates: '사전 정의된 템플릿',
@@ -450,7 +450,7 @@ export const translations = {
uploadFileError: '업로드 실패', uploadFileError: '업로드 실패',
uploadConvertError: '변환 실패', uploadConvertError: '변환 실패',
uploadBatchLimit: '한 번에 최대 10개 파일까지 업로드할 수 있습니다.', uploadBatchLimit: '한 번에 최대 10개 파일까지 업로드할 수 있습니다.',
uploadSizeLimit: '50MB 제한을 초과합니다.', uploadSizeLimit: '100MB 제한을 초과합니다.',
uploading: '업로드 중...', uploading: '업로드 중...',
enableAI: 'AI 활성화', enableAI: 'AI 활성화',
disableAI: 'AI 비활성화', disableAI: 'AI 비활성화',
@@ -530,7 +530,7 @@ export const translations = {
privacyDesc: 'Sende keine IP und Einstellungen an KI', privacyDesc: 'Sende keine IP und Einstellungen an KI',
language: 'Sprache', language: 'Sprache',
auto: 'Automatisch', auto: 'Automatisch',
currency: 'Währung', country: 'Land',
about: 'Über uns', about: 'Über uns',
template: 'Vorlage', template: 'Vorlage',
presetTemplates: 'Vorgabe-Vorlagen', presetTemplates: 'Vorgabe-Vorlagen',
@@ -571,7 +571,7 @@ export const translations = {
uploadFileError: 'Hochladen fehlgeschlagen', uploadFileError: 'Hochladen fehlgeschlagen',
uploadConvertError: 'Konversion fehlgeschlagen', uploadConvertError: 'Konversion fehlgeschlagen',
uploadBatchLimit: 'Maximal 10 Dateien auf einmal.', uploadBatchLimit: 'Maximal 10 Dateien auf einmal.',
uploadSizeLimit: 'Datei überschreitet das Limit von 50MB.', uploadSizeLimit: 'Datei überschreitet das Limit von 100MB.',
uploading: 'Wird hochgeladen...', uploading: 'Wird hochgeladen...',
enableAI: 'KI aktivieren', enableAI: 'KI aktivieren',
disableAI: 'KI deaktivieren', disableAI: 'KI deaktivieren',
@@ -651,7 +651,7 @@ export const translations = {
privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA', privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA',
language: 'Langue', language: 'Langue',
auto: 'Détection auto', auto: 'Détection auto',
currency: 'Devise', country: 'Pays',
about: 'À propos de nous', about: 'À propos de nous',
template: 'Modèle', template: 'Modèle',
presetTemplates: 'Modèles prédéfinis', presetTemplates: 'Modèles prédéfinis',
@@ -692,7 +692,7 @@ export const translations = {
uploadFileError: 'Échec du téléchargement', uploadFileError: 'Échec du téléchargement',
uploadConvertError: 'Échec de la conversion', uploadConvertError: 'Échec de la conversion',
uploadBatchLimit: 'Maximum 10 fichiers à la fois.', 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...', uploading: 'Téléchargement en cours...',
enableAI: 'Activer IA', enableAI: 'Activer IA',
disableAI: 'Désactiver IA', disableAI: 'Désactiver IA',
+77
View File
@@ -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
+455
View File
@@ -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)
}