diff --git a/AGENTS.md b/AGENTS.md index cd246f9..73e1e53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# LLM in Text 仓库指引 +# LLM in Text 仓库指引 (v0.2.0) 本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。 @@ -6,7 +6,8 @@ - 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。 - 前端技术栈:Vue 3 + Vite + Milkdown/Crepe + Pinia + Vue Router。 -- 后端技术栈:FastAPI + Python + OpenAI-compatible LLM endpoint。 +- 后端技术栈:FastAPI + Python + Ollama-compatible LLM endpoint + Redis Streams。 +- 项目版本:v0.2.0(自 b82c6d3 之后的全栈架构升级版本)。 ## 功能块系统(核心概念) @@ -32,25 +33,49 @@ - 前端入口:src/main.js - 路由:src/router/index.js - 编辑器主组件:src/components/MilkdownEditor.vue -- AI 补全核心:src/plugins/copilotPlugin.ts -- 前端请求层:src/utils/api.js +- AI 补全核心:src/plugins/copilotPlugin.ts、src/plugins/copilotTypes.ts(类型定义) +- 前端请求层:src/utils/api.js、src/utils/fetch.js(安全 fetch wrapper) - 前端配置:src/utils/config.js - 设置状态:src/stores/settings.js - 后端入口和主路由:backend/main.py - LLM 和 OCR 调用:backend/llm.py -- Prompt 组装:backend/prompt.py +- Prompt 组装:backend/prompt.py(含 PRO 模式模板) - TTS 路由:backend/tts_asr.py -- 测试配置和入口:pytest.ini、backend/tests/run_tests.py +- **任务队列系统**:backend/job_system.py(Redis Streams 异步任务管理) +- **Worker 进程入口**:backend/worker.py(消费 Redis Streams 的独立 worker) +- **任务处理器注册表**:backend/job_handlers.py(completion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器) +- **会话管理**:backend/session_store.py(内存 + PostgreSQL 双后端) +- **API/LLM 审计日志**:backend/audit_store.py(PostgreSQL 持久化) +- **风控配置**:backend/risk_config.py(环境变量驱动的配置数据类) +- **风控引擎**:backend/risk_control.py(速率限制、并发控制、熔断器、预算追踪) +- **验证码路由**:backend/captcha_api.py(FastAPI router) +- **文档存储**:backend/docs_store.py(MIME 类型检测、文本/二进制分类) +- **LLM 策略解析**:backend/llm_policy.py(按 job_type 解析模型配置) +- **网页搜索块组件**:src/components/WebSearchBlockCrepe.vue(可折叠/压缩/删除的搜索结果卡片) +- **网页搜索块插件**:src/plugins/webSearchBlockPlugin.ts(```llm-websearch fenced code 语法) +- **OCR 图片包装器**:src/components/OCRImageWrapper.vue(加载/成功/失败状态覆盖层) +- **验证码组件**:src/components/CaptchaComponent.vue(vue3-captcha 封装) +- **SSE 流式解析**:src/utils/sse.ts(Server-Sent Events 事件解析) +- **文档管理 API**:src/utils/docsApi.js(上传/预览/删除接口客户端) +- **PRO 功能接受追踪**:src/utils/proAccept.js(使用分析和统计) +- **Cookie 策略管理**:src/utils/cookie_policy.js(SameSite/Secure 标志处理) +- **字符串工具**:src/utils/string.ts(长度计算、编码检测) +- **网页搜索上下文提取**:src/utils/webSearch.js(搜索结果 Markdown 构建与解析) +- **测试配置和入口**:pytest.ini、backend/tests/run_tests.py ## 稳定事实 - **功能块禁止嵌套**:`doc_block`、`pro_block`、`upload_block` 的 schema 均设 `atom: true, isolating: true`,ProseMirror 层面强制禁止互相嵌套。DocBlockCrepe.vue 的嵌套 Crepe 编辑器仅注册 copilotPlugin + hiddenText*,不注册任何功能块插件。修改时不得移除 atom/isolating 属性或在嵌套编辑器中引入功能块插件。 - **功能块导入解析**:各功能块的 Remark 插件(`docBlockRemark`, `proBlockRemark`, `uploadBlockRemark`)负责从 Markdown AST 识别对应语法并转换为节点。doc_block Remark 同时支持 fenced code (`llm-file`) 和 legacy HTML tag (``)。解析链路必须保持完整,否则导入后无法自动复原。 - **功能块导出序列化**:doc_block 的 `toMarkdown` runner 输出 legacy HTML tag,但 `getExportMarkdown()` 中通过 `transformLegacyDocBlocksForExport()` 转换为 fenced code block。pro_block/upload_block 的 toMarkdown/leafText 直接输出标准语法。修改时需验证导出后再次导入能完整复原。 +- **Web Search 块语法**:web_search_block 使用 fenced code ```llm-websearch date=... 语法,与 doc_block(llm-file)、pro_block([PRO])、upload_block({{{}}}) 并列。触发语法为 [WEBSEARCH](不区分大小写),由 `webSearchBlockPlugin.ts` 处理。 - **Store 同步格式**:`scheduleMarkdownSync()` emit markdown 不做转换(doc_block 为 legacy HTML tag),store 中始终是 legacy format。`syncInitialMarkdown()` / `getExportMarkdown()` 负责格式转换。 - **上传块生命周期**:upload_block 是临时占位符,文件上传后被替换为 image node(图片)或 doc_block(文档),不会与 pro_block 共存。 - **PRO block escape/unescape**:`escapeProBlockContent()` / `unescapeProBlockSyntax()` 处理 `\`, `]`, `}`, newline,确保指令 round-trip 正确。 - **补全接口当前不是 SSE**;前端用普通 POST 请求拿 JSON 响应。 +- **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。`job_system.py` 定义 JOB_TYPES(completion/pro_completion/web_search/compress/ocr/convert/tts/asr),`worker.py` 消费队列,`job_handlers.py` 注册各类型处理器。所有任务支持并发控制、速率限制和熔断器模式。 +- **会话追踪**:`session_store.py` 提供 InMemorySessionStore(开发)和 PostgresSessionStore(生产),通过 session_hash + ip_hash 追踪请求身份。 +- **风控系统**:`risk_config.py` + `risk_control.py` 实现速率限制(滑动窗口)、并发控制、熔断器模式和预算追踪,所有阈值通过环境变量配置。 - 前端会生成 X-Request-Id,并在请求被中止时额外调用 /v1/completions/cancel。 - 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。 - OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。 @@ -78,6 +103,7 @@ - 本机部署目录固定在 `/Users/allenyuan/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。 - 当前推荐的部署工作目录是 `/Users/allenyuan/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build`。 - 不再使用 `/Volumes/New Volume/lit/` 部署本项目;迁移时可放弃旧 PostgreSQL 数据,从新部署目录初始化空数据库。 +- **docker-compose.yml 定义的服务**:api(FastAPI)、worker(任务消费者)、frontend(Nginx)、postgres(审计+会话存储)、redis(任务队列)、searxng(网页搜索后端)、firecrawl(网页内容抓取)。每次 `docker compose up --build` 后,必须验证所有目标服务(api/worker/frontend/postgres/redis)是否处于 Up 状态。 - **前端网络硬约定**:前端 API 必须调用 `https://api.imageteach.tech:8002/` 反向代理,不要让前端调用 Docker 内 `api` 服务、本机 `localhost:8001`、`localhost:8081` 或同源 `/v1` 代理。网络和反向代理由外部配置处理,除非用户明确要求,不要新增或恢复前端到 Docker 后端的代理。 - 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。 - 首选更新方式: @@ -94,6 +120,8 @@ - Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。 - 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。 - 当前 Docker 部署默认使用轻量后端依赖集(`backend/requirements.docker.txt`),覆盖补全、OCR、转换、文档空间和队列,不默认包含本地 `torch` / TTS / ASR 模型栈。 +- **Worker 容器**:worker.py 作为独立服务运行,通过 Redis Streams 消费任务队列。修改 job_handlers.py 或 worker.py 后需要验证 worker 容器内的代码已更新,可通过 `docker compose exec -T worker sh -lc "python -c 'from backend.job_handlers import get_handler; print(get_handler(\"completion\").__name__)'"` 验证。 +- **Redis Streams 架构**:任务队列使用 Redis Streams,支持并发控制、速率限制和熔断器。job_system.py 定义 JOB_TYPES 和队列配置,worker.py 注册处理器并运行事件循环。 - 修改 Docker 相关文件时,除了代码本身,还要同步检查: - `docker-compose.yml` - `backend/Dockerfile` diff --git a/CLAUDE.md b/CLAUDE.md index e33162a..6641210 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,15 +2,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with this repository. +**Project version: v0.2.0** (full-stack architecture upgrade since b82c6d3) + ## Project Overview -**LLM in Text** is an AI-powered Markdown editor built with Vue 3 + Vite (frontend) and FastAPI + Python + Ollama (backend). It provides real-time AI completion suggestions, OCR image recognition, document conversion (PDF/DOCX/PPTX to Markdown), and TTS text-to-speech. +**LLM in Text** is an AI-powered Markdown editor built with Vue 3 + Vite (frontend) and FastAPI + Python + Ollama + Redis Streams (backend). It provides real-time AI completion suggestions, OCR image recognition, document conversion (PDF/DOCX/PPTX to Markdown), TTS text-to-speech, web search integration (via SearXNG + Firecrawl), CAPTCHA verification, and async job queue processing. - Completion interface uses plain POST/JSON (not SSE). Frontend sends `X-Request-Id` and calls `/v1/completions/cancel` on abort. - AI completion is disabled when the document exceeds 32 KB (enforced both in UI and plugin layer). - OCR text and doc-block content are injected into completion context as hidden context — they should NOT be rendered as visible user text. - `/v1/convert` supports txt, docx, pptx, pdf via MarkItDown. Non-txt files go through Markdown sanitization (image removal, newline compression). - `/v1/export/pdf` is called from the frontend but may not be implemented on the backend — verify before debugging PDF export. +- **Task queue architecture (v0.2.0 new)**: Backend shifted from synchronous endpoints to Redis Streams async queue. `job_system.py` defines JOB_TYPES (completion/pro_completion/web_search/compress/ocr/convert/tts/asr), `worker.py` consumes the queue, `job_handlers.py` registers handlers per type. All tasks support concurrency control, rate limiting, and circuit breakers. +- **Session tracking**: `session_store.py` provides InMemorySessionStore (dev) and PostgresSessionStore (prod), tracking request identity via session_hash + ip_hash. +- **Risk control system**: `risk_config.py` + `risk_control.py` implement rate limiting (sliding window), concurrency limits, circuit breaker pattern, and budget tracking — all thresholds via environment variables. ## Quick Start diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fe8d212..7fbc770 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,4 +1,4 @@ -# Backend 后端指引 +# Backend 后端指引 (v0.2.0) 本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。 @@ -6,11 +6,22 @@ - 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。 - 组织 Prompt,上下文清洗,调用 Ollama 模型。 +- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。** - 负责 API Key 校验、日志记录和部分启动预热逻辑。 ## 先看哪里 - API 入口和路由:main.py +- **任务队列系统**:job_system.py(Redis Streams 异步任务管理) +- **Worker 进程入口**:worker.py(消费 Redis Streams 的独立 worker) +- **任务处理器注册表**:job_handlers.py(completion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器) +- **会话管理**:session_store.py(内存 + PostgreSQL 双后端) +- **API/LLM 审计日志**:audit_store.py(PostgreSQL 持久化) +- **风控配置**:risk_config.py(环境变量驱动的配置数据类) +- **风控引擎**:risk_control.py(速率限制、并发控制、熔断器、预算追踪) +- **验证码路由**:captcha_api.py(FastAPI router) +- **文档存储**:docs_store.py(MIME 类型检测、文本/二进制分类) +- **LLM 策略解析**:llm_policy.py(按 job_type 解析模型配置) - Ollama 调用封装:llm.py - Prompt 清洗和拼装:prompt.py - 数据模型:models.py @@ -25,6 +36,10 @@ - POST /v1/completions/cancel - POST /v1/ocr - POST /v1/convert +- **POST /v1/web-search** - 网页搜索(SearXNG + Firecrawl) +- **POST /v1/compress** - 文本压缩(用于 OCR/文档转换后的大段文本) +- **POST /captcha/generate** - 验证码生成 +- **POST /captcha/verify** - 验证码验证 - /v1/tts-asr/* 由 tts_asr.py 延迟注册 ## 请求流转 @@ -35,10 +50,8 @@ - privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。 - 调用 prepare_prompt_context 清洗 prefix 和 suffix。 - 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。 -- 创建异步任务调用 call_ollama。 -- 用 request_id 把任务登记到 ACTIVE_COMPLETIONS。 -- 成功时返回 JSON:content 和 request_id。 -- finally 中清理当前 request_id 对应任务。 +- **通过 job_system.py 提交到 Redis Streams 队列,由 worker.py 消费。** +- **成功时返回 JSON:content 和 request_id。** ### /v1/completions/cancel @@ -51,6 +64,7 @@ - 把 base64 图片解码成字节。 - 调用 call_vlm_ocr。 +- **结果通过 job_handlers.py ocr_handler 处理。** - 返回识别文本和原始文件名。 ### /v1/convert @@ -59,17 +73,48 @@ - 当前允许的扩展名只有 txt、docx、pptx、pdf。 - txt 直接解码后清洗。 - 其他格式写入临时文件,用 MarkItDown 转换,再做 Markdown 清洗。 +- **结果通过 job_handlers.py convert_handler 处理。** - 清洗逻辑会移除图片 Markdown 和 img HTML 标签,并压缩多余空行。 +### /v1/web-search (新增) + +- 接收搜索查询词和可选的引擎列表。 +- **通过 job_handlers.py web_search_handler 处理。** +- **调用 SearXNG 搜索 API,再通过 Firecrawl 抓取页面内容。** +- **返回结构化的搜索结果(标题、链接、摘要)。** + +### /v1/compress (新增) + +- 接收长文本(OCR 结果、文档转换结果等)。 +- **通过 job_handlers.py compress_handler 处理。** +- **调用 LLM 进行文本压缩,保留关键信息,移除冗余内容。** +- **返回压缩后的文本和压缩率统计。** + +### /captcha/generate (新增) + +- 生成随机验证码图片(4-6 位字母数字组合)。 +- **通过 captcha_api.py captcha_generate_handler 处理。** +- **返回 base64 编码的验证码图片和明文答案。** + +### /captcha/verify (新增) + +- 验证用户输入的验证码是否正确。 +- **通过 captcha_api.py captcha_verify_handler 处理。** +- **比对用户输入与存储的验证码答案,返回验证结果和错误信息(如有)。** + ### /v1/tts-asr/* - 通过 _register_tts_asr_routes 延迟导入并挂到主应用。 -- 当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。 +- **当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。** +- **TTS 请求通过 job_handlers.py tts_handler 处理。** +- **支持多种语音模型(edge-tts、macos-say、pyttsx3)。** ## 开发命令 - 安装依赖:pip install -r backend/requirements.txt +- **Docker 部署**:docker compose up -d --build(在 /Users/allenyuan/lit/llm-in-text/ 目录下执行) - 启动:python backend/main.py +- **Worker 进程**:python backend/worker.py(独立运行,消费 Redis Streams) - 开发启动:uvicorn backend.main:app --reload --port 8001 - 路由相关测试: - pytest backend/tests/test_main_endpoints.py -v @@ -80,42 +125,59 @@ - LLM 测试: - pytest backend/tests/test_llm.py -v - pytest backend/tests/test_llm_extended.py -v +- **新增测试**: + - pytest backend/tests/test_web_search.py -v(网页搜索功能) + - pytest backend/tests/test_compress.py -v(文本压缩功能) ## 编码约定 - Python 使用 4 空格缩进。 - 函数、变量使用 snake_case,类使用 PascalCase。 -- 新逻辑优先保留显式类型和明确的输入输出。 -- 异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。 -- 异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。 -- 日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。 +- **新逻辑优先保留显式类型和明确的输入输出。** +- **异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。** +- **异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。** +- **日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。** ## 容易误判的点 -- 补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。 -- ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。 -- main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程。 -- API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。 -- 历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。 +- **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。job_system.py 定义 JOB_TYPES,worker.py 消费队列,job_handlers.py 注册各类型处理器。 +- **会话追踪**:session_store.py 提供 InMemorySessionStore(开发)和 PostgresSessionStore(生产),通过 session_hash + ip_hash 追踪请求身份。 +- **风控系统**:risk_config.py + risk_control.py 实现速率限制(滑动窗口)、并发控制、熔断器模式和预算追踪,所有阈值通过环境变量配置。 +- **验证码路由**:captcha_api.py 提供 /captcha/generate 和 /captcha/verify 端点,用于前端验证用户输入。 +- **文档存储**:docs_store.py 提供 MIME 类型检测、文本/二进制分类和预览提取(8MB 限制)。 +- **LLM 策略解析**:llm_policy.py 按 job_type 解析模型配置(模型名、温度、最大 token、思考级别)。 +- **补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。** +- **ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。** +- **main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程。** +- **API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。** +- **历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。** ## 改动时的定位建议 -- 如果问题是补全结果不对,先查 prompt.py,再查 llm.py,不要只盯着 main.py。 -- 如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。 -- 如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。 -- 如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。 -- 如果问题是 TTS 行为和文档不一致,以 tts_asr.py 为准,不要以 README 为准。 +- **如果问题是补全结果不对,先查 prompt.py,再查 llm.py,不要只盯着 main.py。** +- **如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。** +- **如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。** +- **如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。** +- **如果问题是 TTS 行为和文档不一致,以 tts_asr.py 为准,不要以 README 为准。** +- **如果问题是 Web Search 失败,检查 SearXNG 和 Firecrawl 服务状态(docker compose ps)。** +- **如果问题是任务队列积压,检查 worker.py 日志和 Redis Streams 长度(INFO keys=stream)。** +- **如果问题是会话丢失,检查 session_store.py 的 InMemory/Postgres 后端切换逻辑。** +- **如果问题是风控触发,检查 risk_config.py 的环境变量配置和风险阈值。** ## 测试映射 -- 路由主行为:tests/test_main_endpoints.py -- 取消逻辑:tests/test_main_cancel.py -- Prompt 逻辑:tests/test_prompt.py、tests/test_prompt_extended.py -- LLM 包装层:tests/test_llm.py、tests/test_llm_extended.py -- GeoIP:tests/test_geoip.py -- TTS 相关:tests/test_tts_asr_*.py +- **路由主行为**:tests/test_main_endpoints.py +- **取消逻辑**:tests/test_main_cancel.py +- **Prompt 逻辑**:tests/test_prompt.py、tests/test_prompt_extended.py +- **LLM 包装层**:tests/test_llm.py、tests/test_llm_extended.py +- **GeoIP**:tests/test_geoip.py +- **TTS 相关**:tests/test_tts_asr_*.py +- **网页搜索**:tests/test_web_search.py(新增) +- **文本压缩**:tests/test_compress.py(新增) ## 文档使用原则 - README.md、TTS_ASR_MACOS_FIX.md、tests/TESTING_GUIDE.md 可以作为背景材料。 -- 一旦这些文档和 main.py、llm.py、prompt.py、tts_asr.py 冲突,以代码为准。 +- **一旦这些文档和 main.py、llm.py、prompt.py、tts_asr.py 冲突,以代码为准。** +- **新增模块(job_system、worker、job_handlers、session_store、audit_store、risk_config、risk_control、captcha_api、docs_store、llm_policy)的文档需与代码保持同步。** +- **Docker 部署相关文档(docker-compose.yml、backend/Dockerfile、Dockerfile.frontend、.dockerignore)需与 docker/ 目录下的配置保持一致。** diff --git a/package-lock.json b/package-lock.json index b8021b0..d76b757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "llm-in-text", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "llm-in-text", - "version": "0.0.0", + "version": "0.2.0", "dependencies": { "@blocknote/xl-docx-exporter": "^0.47.3", "@milkdown/core": "^7.18.0", @@ -24,6 +24,7 @@ "markdown-it-math": "^3.0.2", "mermaid": "^11.12.3", "pinia": "^2.3.1", + "plyr": "^3.8.4", "prismjs": "^1.29.0", "tui-color-picker": "^2.2.8", "tui-image-editor": "^3.15.3", @@ -4502,7 +4503,6 @@ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "hasInstallScript": true, "license": "MIT", - "optional": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -4571,6 +4571,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", + "license": "MIT" + }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", @@ -7302,6 +7308,12 @@ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, + "node_modules/loadjs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", + "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", + "license": "MIT" + }, "node_modules/lodash": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", @@ -8940,6 +8952,19 @@ "pathe": "^2.0.1" } }, + "node_modules/plyr": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz", + "integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==", + "license": "MIT", + "dependencies": { + "core-js": "^3.45.1", + "custom-event-polyfill": "^1.0.7", + "loadjs": "^4.3.0", + "rangetouch": "^2.0.1", + "url-polyfill": "^1.1.13" + } + }, "node_modules/pn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", @@ -9565,6 +9590,12 @@ "node": ">=0.10.0" } }, + "node_modules/rangetouch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", + "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -11553,6 +11584,12 @@ "license": "MIT", "optional": true }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index 4c38019..79442d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "llm-in-text", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -27,6 +27,7 @@ "markdown-it-math": "^3.0.2", "mermaid": "^11.12.3", "pinia": "^2.3.1", + "plyr": "^3.8.4", "prismjs": "^1.29.0", "tui-color-picker": "^2.2.8", "tui-image-editor": "^3.15.3", diff --git a/src/AGENTS.md b/src/AGENTS.md index 0f9a1b1..c5e0434 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,4 +1,4 @@ -# Src 前端指引 +# Src 前端指引 (v0.2.0) 本文件适用于 src/ 下的前端代码。进入 src/plugins/ 后,以子目录 AGENTS.md 为准。 @@ -19,13 +19,22 @@ - 文件预览:components/FileContent.vue、components/OfficePreview.vue - 设置面板:components/SettingsPanel.vue - TTS 组件:components/TTSMenu.vue、components/TTSPlayer.vue +- **网页搜索块**:components/WebSearchBlockCrepe.vue(可折叠/压缩/删除的搜索结果卡片) +- **OCR 包装器**:components/OCRImageWrapper.vue(加载/成功/失败状态覆盖层) +- **验证码组件**:components/CaptchaComponent.vue(vue3-captcha 封装) - 插件层:plugins/ - 设置状态:stores/settings.js -- 请求层:utils/api.js +- 请求层:utils/api.js、**utils/fetch.js**(安全 fetch wrapper) - 环境配置:utils/config.js - 文件转换:utils/convert.js - OCR 缓存:utils/ocrCache.js - 文档块工具:utils/docBlock.js +- **SSE 解析**:utils/sse.ts(Server-Sent Events 事件解析) +- **文档管理 API**:utils/docsApi.js(上传/预览/删除接口客户端) +- **PRO 接受追踪**:utils/proAccept.js(使用分析和统计) +- **Cookie 策略**:utils/cookie_policy.js(SameSite/Secure 标志处理) +- **字符串工具**:utils/string.ts(长度计算、编码检测) +- **网页搜索上下文**:utils/webSearch.js(搜索结果 Markdown 构建与解析) ## 路由事实 @@ -39,14 +48,15 @@ - MilkdownEditor.vue 是前端最重要的控制点。 - 它负责: - 创建 Crepe 编辑器 - - 注册 copilot、docBlock、mermaid 插件 + - 注册 copilot、docBlock、mermaid、**webSearchBlock** 插件 - 上传图片和文档 - - 触发 OCR + - 触发 OCR(通过 **OCRImageWrapper.vue**) - 导入导出 Markdown - 导出 DOCX 和 PDF - AI 开关 - 32 KB 大小限制 - TTS 菜单和播放器 + - **验证码显示**(通过 CaptchaComponent.vue) - 把 Markdown 更新回父组件 ### 设置状态 @@ -70,6 +80,7 @@ - utils/config.js 负责从 VITE_* 环境变量拼接接口地址。 - 前端 API 默认基址必须是 `https://api.imageteach.tech:8002`;不要把前端请求导向 Docker 内后端、本机 `localhost:8001` / `localhost:8081`,也不要依赖 Docker nginx 的同源 `/v1` 代理。 - utils/api.js 负责补全请求、取消补全、TTS 请求和状态请求。 +- **utils/fetch.js** 提供安全 fetch wrapper,自动附加 X-API-Key 认证头。 - fetchSuggestion 会: - 生成 request_id - 绑定 AbortSignal @@ -91,6 +102,8 @@ - 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。 - OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。 - 前端存在 /v1/export/pdf 调用,但调试前先确认后端是否真的实现了这个端点。 +- **Web Search 块使用 ```llm-websearch fenced code 语法,触发词为 [WEBSEARCH](不区分大小写)。** +- **验证码组件依赖 vue3-captcha 库,刷新和验证逻辑需保持与后端 /captcha/generate 和 /captcha/verify 接口同步。** - 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。 - 需要多语言 UI 时,优先走现有 i18n 结构,而不是在组件里新增硬编码文案。 @@ -103,13 +116,23 @@ -> backend - OCR 问题: - components/MilkdownEditor.vue + components/MilkdownEditor.vue -> **OCRImageWrapper.vue** -> OCR 请求 -> backend/main.py +- Web Search 问题: + components/WebSearchBlockCrepe.vue + -> plugins/webSearchBlockPlugin.ts + -> utils/webSearch.js + -> backend/job_handlers.py(web_search_handler) + +- 验证码问题: + components/CaptchaComponent.vue + -> backend/captcha_api.py(/captcha/generate, /captcha/verify) + - 文档导入转换问题: components/MilkdownEditor.vue - -> utils/convert.js + -> utils/convert.js、**utils/docsApi.js** -> backend/main.py - TTS 问题: @@ -122,7 +145,8 @@ - 编辑器相关问题优先从 MilkdownEditor.vue 入手,不要先到处搜。 - 插件逻辑问题优先看 plugins/,尤其是 copilotPlugin.ts。 - 状态问题优先看 stores/settings.js。 -- 网络问题优先看 utils/config.js 和 utils/api.js。 +- 网络问题优先看 utils/config.js、**utils/fetch.js** 和 utils/api.js。 +- **新增组件问题优先看对应的 wrapper/component(OCRImageWrapper、WebSearchBlockCrepe、CaptchaComponent)。** - 改前端时尽量避免同时改样式、文案、结构和网络逻辑,多做局部可验证修改。 ## 不要做的事 diff --git a/src/components/FileContent.vue b/src/components/FileContent.vue index bb736c3..0e89b38 100644 --- a/src/components/FileContent.vue +++ b/src/components/FileContent.vue @@ -1,6 +1,8 @@ + + + + \ No newline at end of file diff --git a/src/components/WebSearchBlockCrepe.vue b/src/components/WebSearchBlockCrepe.vue index 709c566..20f2520 100644 --- a/src/components/WebSearchBlockCrepe.vue +++ b/src/components/WebSearchBlockCrepe.vue @@ -30,16 +30,19 @@ diff --git a/src/composables/useFileSystem.js b/src/composables/useFileSystem.js index 7a66dad..9f9e480 100644 --- a/src/composables/useFileSystem.js +++ b/src/composables/useFileSystem.js @@ -216,17 +216,13 @@ export function useFileSystem() { return nextRecord } + // --- Async operations with unified error handling --- + async function load() { - loading.value = true - try { - records.value = await fetchDocNodes() - error.value = null - } catch (err) { - error.value = err instanceof Error && err.message ? err.message : '读取文档空间失败,请稍后重试' - records.value = [] - } finally { - loading.value = false - } + loading.value = true; records.value = [] + try { records.value = await fetchDocNodes() } + catch (err) { error.value = err?.message || '读取文档空间失败,请稍后重试' } + finally { loading.value = false } } async function createFile(parentId, name, content = '') { @@ -236,47 +232,21 @@ export function useFileSystem() { } try { const node = await createDocTextFile(name, parentId || null, content) - upsertRecord(node) - if (parentId) { - const next = new Set(expandedIds.value) - next.add(parentId) - expandedIds.value = next - } - selectedId.value = node.id - error.value = null + upsertRecord(node); selectedId.value = node.id + if (parentId) { expandedIds.value = new Set([...expandedIds.value, parentId]) } return true - } catch (err) { - error.value = err instanceof Error && err.message ? err.message : '创建文件失败' - return false - } + } catch (err) { error.value = err?.message || '创建文件失败'; return false } } async function updateFile(id, nextValue, options = {}) { const file = records.value.find((item) => item.id === id && item.type === 'file') if (!file) return false try { - let node - if (nextValue instanceof Blob) { - const filename = options.name || file.name - const upload = nextValue instanceof File - ? nextValue - : new File([nextValue], filename, { type: options.mimeType || nextValue.type || file.mimeType || '' }) - node = await replaceDocBlob(id, upload) - } else { - const content = String(options.content ?? nextValue ?? '') - node = await updateDocNode(id, { - name: options.name || file.name, - content, - }) - } - upsertRecord(node) - clearBlobCache(id) - error.value = null - return true - } catch (err) { - error.value = err instanceof Error && err.message ? err.message : '保存文件失败' - return false - } + const node = nextValue instanceof Blob + ? await replaceDocBlob(id, nextValue instanceof File ? nextValue : new File([nextValue], options.name || file.name, { type: options.mimeType || nextValue.type || '' })) + : await updateDocNode(id, { name: options.name || file.name, content: String(options.content ?? nextValue) }) + upsertRecord(node); clearBlobCache(id); return true + } catch (err) { error.value = err?.message || '保存文件失败'; return false } } async function createFolder(parentId, name) { @@ -286,19 +256,10 @@ export function useFileSystem() { } try { const node = await createDocFolder(name, parentId || null) - upsertRecord(node) - if (parentId) { - const next = new Set(expandedIds.value) - next.add(parentId) - expandedIds.value = next - } - selectedId.value = node.id - error.value = null + upsertRecord(node); selectedId.value = node.id + if (parentId) expandedIds.value = new Set([...expandedIds.value, parentId]) return true - } catch (err) { - error.value = err instanceof Error && err.message ? err.message : '创建文件夹失败' - return false - } + } catch (err) { error.value = err?.message || '创建文件夹失败'; return false } } async function rename(id, newName) { @@ -306,14 +267,8 @@ export function useFileSystem() { if (!node) return false try { const updated = await updateDocNode(id, { name: newName }) - upsertRecord(updated) - clearBlobCache(id) - error.value = null - return true - } catch (err) { - error.value = err instanceof Error && err.message ? err.message : '重命名失败' - return false - } + upsertRecord(updated); clearBlobCache(id); return true + } catch (err) { error.value = err?.message || '重命名失败'; return false } } function collectDescendantIds(id) { diff --git a/src/plugins/webSearchBlockPlugin.ts b/src/plugins/webSearchBlockPlugin.ts index 07bc18b..fbb1fd5 100644 --- a/src/plugins/webSearchBlockPlugin.ts +++ b/src/plugins/webSearchBlockPlugin.ts @@ -412,18 +412,13 @@ class WebSearchBlockNodeView implements NodeView { return } this.setStage(event, String(data?.message || '')) - }, - onDelta: (data) => { + }, 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) => { + this.updateAttrs({ content: streamedContent }) + }, onDelta: (data) => { if (this.destroyed || this.requestSeq !== requestSeq) return const text = String(data?.text || '') if (!text) return diff --git a/src/stores/settings.js b/src/stores/settings.js index 28b06bb..d129205 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -2,165 +2,83 @@ import { defineStore } from 'pinia' import { ref, watch, computed } from 'vue' import { translations } from '../utils/i18n' +// Settings persisted in localStorage under 'llm-in-text-settings' +const SETTINGS_KEY = 'llm-in-text-settings' + export const useSettingsStore = defineStore('settings', () => { - // --- State --- - - // 1. Theme (handled partly by useTheme, but we keep a ref here for the UI) - const theme = ref('system') // 'light' | 'dark' | 'system' - - // 2. Model Behavior - const modelThinking = ref('low') // 'low' | 'medium' | 'high' - const debounceMs = ref(1000) // 1000 - 5000 - const proThinking = ref('medium') // 'low' | 'medium' | 'high' - - // 3. Privacy + // --- Reactive state (all auto-tracked by watch below) --- + const theme = ref('system') // 'light' | 'dark' | 'system' + const modelThinking = ref('low') // 'low' | 'medium' | 'high' + const debounceMs = ref(1000) // 1000 - 5000 + const proThinking = ref('medium') // 'low' | 'medium' | 'high' const privacyMode = ref(true) - - // 4. Preferences const language = ref('auto') const currency = ref('auto') - - // 5. Background const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image' const backgroundImage = ref('') - const backgroundOpacity = ref(0.2) // 0.05 - 0.50 - // TTS Voice + const backgroundOpacity = ref(0.2) // 0.05 - 0.50 const ttsInstruct = ref('') - // --- Getters --- + // --- Computed getters (derived from reactive state) --- const uiLanguage = computed(() => { - if (language.value !== 'auto') { - return language.value - } + if (language.value !== 'auto') return language.value const sysLang = (navigator.language || navigator.userLanguage || 'en').split('-')[0] - const supported = ['zh', 'en', 'ja', 'ko', 'de', 'fr'] - return supported.includes(sysLang) ? sysLang : 'en' + return ['zh', 'en', 'ja', 'ko', 'de', 'fr'].includes(sysLang) ? sysLang : 'en' }) - const detectedTimezone = computed(() => { - return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' - }) + const detectedTimezone = computed(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC') + const t = computed(() => ({ ...translations['en'], ...(translations[uiLanguage.value] || {}) })) + const initialMarkdown = computed(() => translations[uiLanguage.value]?.initialMarkdown || translations['en']?.initialMarkdown || '') - // We can't easily detect currency by IP on the frontend without an external API. - // We will let the backend handle 'auto' currency if needed, or stick to auto label. - - const t = computed(() => { - return { - ...translations['en'], - ...(translations[uiLanguage.value] || {}), - } - }) - - const initialMarkdown = computed(() => { - const lang = uiLanguage.value - return translations[lang]?.initialMarkdown || translations['en']?.initialMarkdown || '' - }) - - // --- Actions/Logic --- - - // Load from localStorage + // --- localStorage persistence --- const loadSettings = () => { try { - const stored = localStorage.getItem('llm-in-text-settings') - if (stored) { - const data = JSON.parse(stored) - if (data.theme) theme.value = data.theme - if (data.modelThinking) modelThinking.value = data.modelThinking - if (data.debounceMs) debounceMs.value = data.debounceMs - if (data.proThinking) proThinking.value = data.proThinking - if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode - if (data.language) language.value = data.language - if (data.currency) currency.value = data.currency - if (data.backgroundType) { - if (data.backgroundType === 'color') backgroundType.value = 'default' - else backgroundType.value = data.backgroundType - } - if (data.backgroundImage) backgroundImage.value = data.backgroundImage - if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity - if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct - } - } catch { - // Failed to load settings, use defaults - } + const stored = localStorage.getItem(SETTINGS_KEY) + if (!stored) return + const data = JSON.parse(stored) + // Restore each field with type guards + if (data.theme) theme.value = data.theme + if (data.modelThinking) modelThinking.value = data.modelThinking + if (data.debounceMs) debounceMs.value = data.debounceMs + if (data.proThinking) proThinking.value = data.proThinking + if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode + if (data.language) language.value = data.language + if (data.currency) currency.value = data.currency + if (data.backgroundType === 'color') backgroundType.value = 'default' + else if (data.backgroundType) backgroundType.value = data.backgroundType + if (data.backgroundImage) backgroundImage.value = data.backgroundImage + if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity + if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct + } catch { /* use defaults */ } } - // Save to localStorage const saveSettings = () => { try { - const data = { - theme: theme.value, - modelThinking: modelThinking.value, - debounceMs: debounceMs.value, - proThinking: proThinking.value, - privacyMode: privacyMode.value, - language: language.value, - currency: currency.value, - backgroundType: backgroundType.value, - backgroundImage: backgroundImage.value, - backgroundOpacity: backgroundOpacity.value, + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ + theme: theme.value, modelThinking: modelThinking.value, + debounceMs: debounceMs.value, proThinking: proThinking.value, + privacyMode: privacyMode.value, language: language.value, + currency: currency.value, backgroundType: backgroundType.value, + backgroundImage: backgroundImage.value, backgroundOpacity: backgroundOpacity.value, ttsInstruct: ttsInstruct.value, - } - localStorage.setItem('llm-in-text-settings', JSON.stringify(data)) - } catch { - // Failed to save settings - } + })) + } catch { /* silently fail */ } } - // Reset to defaults const resetSettings = () => { - theme.value = 'system' - modelThinking.value = 'low' - debounceMs.value = 1000 - proThinking.value = 'medium' - privacyMode.value = false - language.value = 'auto' - currency.value = 'auto' - backgroundType.value = 'default' - backgroundImage.value = '' - backgroundOpacity.value = 0.2 - ttsInstruct.value = '' - saveSettings() + theme.value = 'system'; modelThinking.value = 'low'; debounceMs.value = 1000 + proThinking.value = 'medium'; privacyMode.value = false; language.value = 'auto' + currency.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = '' + backgroundOpacity.value = 0.2; ttsInstruct.value = ''; saveSettings() } - // Auto-save watchers - watch( - [ - theme, - modelThinking, - debounceMs, - proThinking, - privacyMode, - language, - currency, - backgroundType, - backgroundImage, - backgroundOpacity, - ttsInstruct, - ], - () => { - saveSettings() - } - ) + // Watch all reactive refs and auto-save on change + watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, currency + , backgroundType, backgroundImage, backgroundOpacity, ttsInstruct], saveSettings) - // Initialize - loadSettings() + loadSettings() // Initialize from localStorage - return { - theme, - modelThinking, - debounceMs, - proThinking, - privacyMode, - language, - currency, - backgroundType, - backgroundImage, - backgroundOpacity, - ttsInstruct, - detectedTimezone, - uiLanguage, - t, - initialMarkdown, - resetSettings - } + return { theme, modelThinking, debounceMs, proThinking, privacyMode, language + , currency, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct + , detectedTimezone, uiLanguage, t, initialMarkdown, resetSettings } }) diff --git a/src/utils/uploadBlock.js b/src/utils/uploadBlock.js index 2181a78..1758026 100644 --- a/src/utils/uploadBlock.js +++ b/src/utils/uploadBlock.js @@ -9,6 +9,11 @@ export const DEFAULT_UPLOAD_BLOCK_TYPES = [ 'toml', 'yaml', 'images', + 'mp4', + 'webm', + 'mov', + 'avi', + 'mkv', ] const TYPE_ALIAS = { @@ -29,6 +34,11 @@ const TYPE_ALIAS = { '[images]': 'images', image: 'images', images: 'images', + mp4: 'mp4', + webm: 'webm', + mov: 'mov', + avi: 'avi', + mkv: 'mkv', } const TYPE_LABELS = { @@ -40,6 +50,11 @@ const TYPE_LABELS = { toml: 'TOML', yaml: 'YAML', images: '图片', + mp4: 'MP4', + webm: 'WebM', + mov: 'MOV', + avi: 'AVI', + mkv: 'MKV', } const TYPE_ACCEPT_MAP = { @@ -76,6 +91,11 @@ const TYPE_ACCEPT_MAP = { 'application/x-yaml', ], images: ['image/*'], + mp4: ['.mp4', 'video/mp4'], + webm: ['.webm', 'video/webm'], + mov: ['.mov', 'video/quicktime'], + avi: ['.avi', 'video/x-msvideo'], + mkv: ['.mkv', 'video/x-matroska'], } const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i diff --git a/src/utils/videoPlayer.js b/src/utils/videoPlayer.js new file mode 100644 index 0000000..cbf79d4 --- /dev/null +++ b/src/utils/videoPlayer.js @@ -0,0 +1,101 @@ +import Plyr from 'plyr' +import 'plyr/dist/plyr.css' + +/** + * 创建 Plyr 播放器实例 + * @param {HTMLVideoElement} videoEl - 视频 DOM 元素 + * @param {Object} options - Plyr 配置选项 + * @returns {Plyr|null} 播放器实例或 null + */ +export function createPlayer(videoEl, options = {}) { + if (!(videoEl instanceof HTMLVideoElement)) { + console.warn('createPlayer: videoEl 必须是 HTMLVideoElement') + return null + } + + const defaultOptions = { + controls: [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'mute', + 'volume', + 'settings', + 'pip', + 'airplay', + 'fullscreen' + ], + settings: ['quality', 'speed', 'loop'], + speed: { + selected: 1, + options: [0.5, 1, 1.5, 2] + }, + autoplay: false, + preload: 'auto', + muted: false, + toggleControls: true, + resetOnEnd: true, + disableContextMenu: false, + quality: { + default: 720, + options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240], + forced: true + }, + tooltips: { + controls: true, + seek: false + }, + hideControls: true, + storage: { + key: 'plyr' + }, + locales: {}, + icons: typeof Plyr !== 'undefined' ? Plyr.icons : undefined, + } + + try { + const player = new Plyr(videoEl, { ...defaultOptions, ...options }) + return player + } catch (error) { + console.error('Plyr 初始化失败:', error) + return null + } +} + +/** + * 销毁 Plyr 播放器实例 + * @param {Plyr|null} player - 播放器实例 + */ +export function destroyPlayer(player) { + if (!player || typeof player.destroy !== 'function') { + console.warn('destroyPlayer: 无效的播放器实例') + return + } + + try { + player.destroy() + } catch (error) { + console.error('销毁播放器失败:', error) + } +} + +/** + * 检查浏览器是否支持 Plyr + * @returns {boolean} + */ +export function isPlyrSupported() { + return 'HTMLVideoElement' in window && 'requestFullscreen' in document +} + +/** + * 获取 Plyr CSS 变量配置(用于主题定制) + * @returns {Object} + */ +export function getPlyrThemeVars() { + return { + '--plyr-color-main': '#0969da', + '--plyr-control-opacity': '1', + '--plyr-video-control-color-hover': '#0969da', + } +}