feat: update AGENTS.md with new components and API details; enhance FileContent.vue with Plyr video support; add VideoPlayer.vue for video playback; improve MilkdownEditor.vue for video file handling; refine TTSPlayer.vue volume controls; optimize settings store for localStorage; implement video upload support in uploadBlock.js; create utility functions for Plyr player management.

This commit is contained in:
“ydy0615”
2026-06-13 11:03:40 +08:00
parent 2283020e51
commit 4813196b0a
16 changed files with 767 additions and 275 deletions
+34 -6
View File
@@ -1,4 +1,4 @@
# LLM in Text 仓库指引 # LLM in Text 仓库指引 (v0.2.0)
本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。 本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。
@@ -6,7 +6,8 @@
- 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。 - 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。
- 前端技术栈:Vue 3 + Vite + Milkdown/Crepe + Pinia + Vue Router。 - 前端技术栈: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/main.js
- 路由:src/router/index.js - 路由:src/router/index.js
- 编辑器主组件:src/components/MilkdownEditor.vue - 编辑器主组件:src/components/MilkdownEditor.vue
- AI 补全核心:src/plugins/copilotPlugin.ts - AI 补全核心:src/plugins/copilotPlugin.ts、src/plugins/copilotTypes.ts(类型定义)
- 前端请求层:src/utils/api.js - 前端请求层:src/utils/api.js、src/utils/fetch.js(安全 fetch wrapper
- 前端配置:src/utils/config.js - 前端配置:src/utils/config.js
- 设置状态:src/stores/settings.js - 设置状态:src/stores/settings.js
- 后端入口和主路由:backend/main.py - 后端入口和主路由:backend/main.py
- LLM 和 OCR 调用:backend/llm.py - LLM 和 OCR 调用:backend/llm.py
- Prompt 组装:backend/prompt.py - Prompt 组装:backend/prompt.py(含 PRO 模式模板)
- TTS 路由:backend/tts_asr.py - TTS 路由:backend/tts_asr.py
- 测试配置和入口:pytest.ini、backend/tests/run_tests.py - **任务队列系统**backend/job_system.pyRedis Streams 异步任务管理)
- **Worker 进程入口**backend/worker.py(消费 Redis Streams 的独立 worker
- **任务处理器注册表**backend/job_handlers.pycompletion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器)
- **会话管理**backend/session_store.py(内存 + PostgreSQL 双后端)
- **API/LLM 审计日志**backend/audit_store.pyPostgreSQL 持久化)
- **风控配置**backend/risk_config.py(环境变量驱动的配置数据类)
- **风控引擎**backend/risk_control.py(速率限制、并发控制、熔断器、预算追踪)
- **验证码路由**backend/captcha_api.pyFastAPI router
- **文档存储**backend/docs_store.pyMIME 类型检测、文本/二进制分类)
- **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.vuevue3-captcha 封装)
- **SSE 流式解析**src/utils/sse.tsServer-Sent Events 事件解析)
- **文档管理 API**src/utils/docsApi.js(上传/预览/删除接口客户端)
- **PRO 功能接受追踪**src/utils/proAccept.js(使用分析和统计)
- **Cookie 策略管理**src/utils/cookie_policy.jsSameSite/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 属性或在嵌套编辑器中引入功能块插件。 - **功能块禁止嵌套**`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_type=...>`)。解析链路必须保持完整,否则导入后无法自动复原。 - **功能块导入解析**:各功能块的 Remark 插件(`docBlockRemark`, `proBlockRemark`, `uploadBlockRemark`)负责从 Markdown AST 识别对应语法并转换为节点。doc_block Remark 同时支持 fenced code (`llm-file`) 和 legacy HTML tag (`<doc_type=...>`)。解析链路必须保持完整,否则导入后无法自动复原。
- **功能块导出序列化**doc_block 的 `toMarkdown` runner 输出 legacy HTML tag,但 `getExportMarkdown()` 中通过 `transformLegacyDocBlocksForExport()` 转换为 fenced code block。pro_block/upload_block 的 toMarkdown/leafText 直接输出标准语法。修改时需验证导出后再次导入能完整复原。 - **功能块导出序列化**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()` 负责格式转换。 - **Store 同步格式**`scheduleMarkdownSync()` emit markdown 不做转换(doc_block 为 legacy HTML tag),store 中始终是 legacy format。`syncInitialMarkdown()` / `getExportMarkdown()` 负责格式转换。
- **上传块生命周期**upload_block 是临时占位符,文件上传后被替换为 image node(图片)或 doc_block(文档),不会与 pro_block 共存。 - **上传块生命周期**upload_block 是临时占位符,文件上传后被替换为 image node(图片)或 doc_block(文档),不会与 pro_block 共存。
- **PRO block escape/unescape**`escapeProBlockContent()` / `unescapeProBlockSyntax()` 处理 `\`, `]`, `}`, newline,确保指令 round-trip 正确。 - **PRO block escape/unescape**`escapeProBlockContent()` / `unescapeProBlockSyntax()` 处理 `\`, `]`, `}`, newline,确保指令 round-trip 正确。
- **补全接口当前不是 SSE**;前端用普通 POST 请求拿 JSON 响应。 - **补全接口当前不是 SSE**;前端用普通 POST 请求拿 JSON 响应。
- **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。`job_system.py` 定义 JOB_TYPEScompletion/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。 - 前端会生成 X-Request-Id,并在请求被中止时额外调用 /v1/completions/cancel。
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。 - 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。 - OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
@@ -78,6 +103,7 @@
- 本机部署目录固定在 `/Users/allenyuan/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。 - 本机部署目录固定在 `/Users/allenyuan/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。
- 当前推荐的部署工作目录是 `/Users/allenyuan/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build` - 当前推荐的部署工作目录是 `/Users/allenyuan/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build`
- 不再使用 `/Volumes/New Volume/lit/` 部署本项目;迁移时可放弃旧 PostgreSQL 数据,从新部署目录初始化空数据库。 - 不再使用 `/Volumes/New Volume/lit/` 部署本项目;迁移时可放弃旧 PostgreSQL 数据,从新部署目录初始化空数据库。
- **docker-compose.yml 定义的服务**apiFastAPI)、worker(任务消费者)、frontendNginx)、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 后端的代理。 - **前端网络硬约定**:前端 API 必须调用 `https://api.imageteach.tech:8002/` 反向代理,不要让前端调用 Docker 内 `api` 服务、本机 `localhost:8001``localhost:8081` 或同源 `/v1` 代理。网络和反向代理由外部配置处理,除非用户明确要求,不要新增或恢复前端到 Docker 后端的代理。
- 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。 - 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。
- 首选更新方式: - 首选更新方式:
@@ -94,6 +120,8 @@
- 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` / 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 相关文件时,除了代码本身,还要同步检查:
- `docker-compose.yml` - `docker-compose.yml`
- `backend/Dockerfile` - `backend/Dockerfile`
+6 -1
View File
@@ -2,15 +2,20 @@
This file provides guidance to Claude Code (claude.ai/code) when working with this repository. 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 ## 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. - 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). - 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. - 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/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. - `/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 ## Quick Start
+89 -27
View File
@@ -1,4 +1,4 @@
# Backend 后端指引 # Backend 后端指引 (v0.2.0)
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。 本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
@@ -6,11 +6,22 @@
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。 - 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
- 组织 Prompt,上下文清洗,调用 Ollama 模型。 - 组织 Prompt,上下文清洗,调用 Ollama 模型。
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
- 负责 API Key 校验、日志记录和部分启动预热逻辑。 - 负责 API Key 校验、日志记录和部分启动预热逻辑。
## 先看哪里 ## 先看哪里
- API 入口和路由:main.py - API 入口和路由:main.py
- **任务队列系统**job_system.pyRedis Streams 异步任务管理)
- **Worker 进程入口**worker.py(消费 Redis Streams 的独立 worker
- **任务处理器注册表**job_handlers.pycompletion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器)
- **会话管理**session_store.py(内存 + PostgreSQL 双后端)
- **API/LLM 审计日志**audit_store.pyPostgreSQL 持久化)
- **风控配置**risk_config.py(环境变量驱动的配置数据类)
- **风控引擎**risk_control.py(速率限制、并发控制、熔断器、预算追踪)
- **验证码路由**captcha_api.pyFastAPI router
- **文档存储**docs_store.pyMIME 类型检测、文本/二进制分类)
- **LLM 策略解析**llm_policy.py(按 job_type 解析模型配置)
- Ollama 调用封装:llm.py - Ollama 调用封装:llm.py
- Prompt 清洗和拼装:prompt.py - Prompt 清洗和拼装:prompt.py
- 数据模型:models.py - 数据模型:models.py
@@ -25,6 +36,10 @@
- POST /v1/completions/cancel - POST /v1/completions/cancel
- POST /v1/ocr - POST /v1/ocr
- POST /v1/convert - 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 延迟注册 - /v1/tts-asr/* 由 tts_asr.py 延迟注册
## 请求流转 ## 请求流转
@@ -35,10 +50,8 @@
- privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。 - privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。
- 调用 prepare_prompt_context 清洗 prefix 和 suffix。 - 调用 prepare_prompt_context 清洗 prefix 和 suffix。
- 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。 - 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。
- 创建异步任务调用 call_ollama。 - **通过 job_system.py 提交到 Redis Streams 队列,由 worker.py 消费。**
- 用 request_id 把任务登记到 ACTIVE_COMPLETIONS。 - **成功时返回 JSONcontent 和 request_id。**
- 成功时返回 JSONcontent 和 request_id。
- finally 中清理当前 request_id 对应任务。
### /v1/completions/cancel ### /v1/completions/cancel
@@ -51,6 +64,7 @@
- 把 base64 图片解码成字节。 - 把 base64 图片解码成字节。
- 调用 call_vlm_ocr。 - 调用 call_vlm_ocr。
- **结果通过 job_handlers.py ocr_handler 处理。**
- 返回识别文本和原始文件名。 - 返回识别文本和原始文件名。
### /v1/convert ### /v1/convert
@@ -59,17 +73,48 @@
- 当前允许的扩展名只有 txt、docx、pptx、pdf。 - 当前允许的扩展名只有 txt、docx、pptx、pdf。
- txt 直接解码后清洗。 - txt 直接解码后清洗。
- 其他格式写入临时文件,用 MarkItDown 转换,再做 Markdown 清洗。 - 其他格式写入临时文件,用 MarkItDown 转换,再做 Markdown 清洗。
- **结果通过 job_handlers.py convert_handler 处理。**
- 清洗逻辑会移除图片 Markdown 和 img HTML 标签,并压缩多余空行。 - 清洗逻辑会移除图片 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/* ### /v1/tts-asr/*
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。 - 通过 _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 - 安装依赖:pip install -r backend/requirements.txt
- **Docker 部署**docker compose up -d --build(在 /Users/allenyuan/lit/llm-in-text/ 目录下执行)
- 启动:python backend/main.py - 启动:python backend/main.py
- **Worker 进程**python backend/worker.py(独立运行,消费 Redis Streams
- 开发启动:uvicorn backend.main:app --reload --port 8001 - 开发启动:uvicorn backend.main:app --reload --port 8001
- 路由相关测试: - 路由相关测试:
- pytest backend/tests/test_main_endpoints.py -v - pytest backend/tests/test_main_endpoints.py -v
@@ -80,42 +125,59 @@
- LLM 测试: - LLM 测试:
- pytest backend/tests/test_llm.py -v - pytest backend/tests/test_llm.py -v
- pytest backend/tests/test_llm_extended.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 空格缩进。 - Python 使用 4 空格缩进。
- 函数、变量使用 snake_case,类使用 PascalCase。 - 函数、变量使用 snake_case,类使用 PascalCase。
- 新逻辑优先保留显式类型和明确的输入输出。 - **新逻辑优先保留显式类型和明确的输入输出。**
- 异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。 - **异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。**
- 异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。 - **异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。**
- 日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。 - **日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。**
## 容易误判的点 ## 容易误判的点
- 补全接口当前不是流式响应,不要按 SSE 方式改造周边代码 - **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。job_system.py 定义 JOB_TYPESworker.py 消费队列,job_handlers.py 注册各类型处理器
- ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理 - **会话追踪**session_store.py 提供 InMemorySessionStore(开发)和 PostgresSessionStore(生产),通过 session_hash + ip_hash 追踪请求身份
- main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程 - **风控系统**risk_config.py + risk_control.py 实现速率限制(滑动窗口)、并发控制、熔断器模式和预算追踪,所有阈值通过环境变量配置
- API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式 - **验证码路由**captcha_api.py 提供 /captcha/generate 和 /captcha/verify 端点,用于前端验证用户输入
- 历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档 - **文档存储**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。 - **如果问题是补全结果不对,先查 prompt.py,再查 llm.py,不要只盯着 main.py。**
- 如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。 - **如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。**
- 如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。 - **如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。**
- 如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。 - **如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。**
- 如果问题是 TTS 行为和文档不一致,以 tts_asr.py 为准,不要以 README 为准。 - **如果问题是 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_endpoints.py
- 取消逻辑:tests/test_main_cancel.py - **取消逻辑**tests/test_main_cancel.py
- Prompt 逻辑:tests/test_prompt.py、tests/test_prompt_extended.py - **Prompt 逻辑**tests/test_prompt.py、tests/test_prompt_extended.py
- LLM 包装层:tests/test_llm.py、tests/test_llm_extended.py - **LLM 包装层**tests/test_llm.py、tests/test_llm_extended.py
- GeoIPtests/test_geoip.py - **GeoIP**tests/test_geoip.py
- TTS 相关:tests/test_tts_asr_*.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 可以作为背景材料。 - 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/ 目录下的配置保持一致。**
+40 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "llm-in-text", "name": "llm-in-text",
"version": "0.0.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "llm-in-text", "name": "llm-in-text",
"version": "0.0.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3", "@blocknote/xl-docx-exporter": "^0.47.3",
"@milkdown/core": "^7.18.0", "@milkdown/core": "^7.18.0",
@@ -24,6 +24,7 @@
"markdown-it-math": "^3.0.2", "markdown-it-math": "^3.0.2",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"plyr": "^3.8.4",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"tui-color-picker": "^2.2.8", "tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3", "tui-image-editor": "^3.15.3",
@@ -4502,7 +4503,6 @@
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
@@ -4571,6 +4571,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/cytoscape": {
"version": "3.33.1", "version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
@@ -7302,6 +7308,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT" "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": { "node_modules/lodash": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
@@ -8940,6 +8952,19 @@
"pathe": "^2.0.1" "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": { "node_modules/pn": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz",
@@ -9565,6 +9590,12 @@
"node": ">=0.10.0" "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": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -11553,6 +11584,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/use": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "llm-in-text", "name": "llm-in-text",
"private": true, "private": true,
"version": "0.0.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -27,6 +27,7 @@
"markdown-it-math": "^3.0.2", "markdown-it-math": "^3.0.2",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"plyr": "^3.8.4",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"tui-color-picker": "^2.2.8", "tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3", "tui-image-editor": "^3.15.3",
+31 -7
View File
@@ -1,4 +1,4 @@
# Src 前端指引 # Src 前端指引 (v0.2.0)
本文件适用于 src/ 下的前端代码。进入 src/plugins/ 后,以子目录 AGENTS.md 为准。 本文件适用于 src/ 下的前端代码。进入 src/plugins/ 后,以子目录 AGENTS.md 为准。
@@ -19,13 +19,22 @@
- 文件预览:components/FileContent.vue、components/OfficePreview.vue - 文件预览:components/FileContent.vue、components/OfficePreview.vue
- 设置面板:components/SettingsPanel.vue - 设置面板:components/SettingsPanel.vue
- TTS 组件:components/TTSMenu.vue、components/TTSPlayer.vue - TTS 组件:components/TTSMenu.vue、components/TTSPlayer.vue
- **网页搜索块**components/WebSearchBlockCrepe.vue(可折叠/压缩/删除的搜索结果卡片)
- **OCR 包装器**components/OCRImageWrapper.vue(加载/成功/失败状态覆盖层)
- **验证码组件**components/CaptchaComponent.vuevue3-captcha 封装)
- 插件层:plugins/ - 插件层:plugins/
- 设置状态:stores/settings.js - 设置状态:stores/settings.js
- 请求层:utils/api.js - 请求层:utils/api.js、**utils/fetch.js**(安全 fetch wrapper
- 环境配置:utils/config.js - 环境配置:utils/config.js
- 文件转换:utils/convert.js - 文件转换:utils/convert.js
- OCR 缓存:utils/ocrCache.js - OCR 缓存:utils/ocrCache.js
- 文档块工具:utils/docBlock.js - 文档块工具:utils/docBlock.js
- **SSE 解析**utils/sse.tsServer-Sent Events 事件解析)
- **文档管理 API**utils/docsApi.js(上传/预览/删除接口客户端)
- **PRO 接受追踪**utils/proAccept.js(使用分析和统计)
- **Cookie 策略**utils/cookie_policy.jsSameSite/Secure 标志处理)
- **字符串工具**utils/string.ts(长度计算、编码检测)
- **网页搜索上下文**utils/webSearch.js(搜索结果 Markdown 构建与解析)
## 路由事实 ## 路由事实
@@ -39,14 +48,15 @@
- MilkdownEditor.vue 是前端最重要的控制点。 - MilkdownEditor.vue 是前端最重要的控制点。
- 它负责: - 它负责:
- 创建 Crepe 编辑器 - 创建 Crepe 编辑器
- 注册 copilot、docBlock、mermaid 插件 - 注册 copilot、docBlock、mermaid、**webSearchBlock** 插件
- 上传图片和文档 - 上传图片和文档
- 触发 OCR - 触发 OCR(通过 **OCRImageWrapper.vue**
- 导入导出 Markdown - 导入导出 Markdown
- 导出 DOCX 和 PDF - 导出 DOCX 和 PDF
- AI 开关 - AI 开关
- 32 KB 大小限制 - 32 KB 大小限制
- TTS 菜单和播放器 - TTS 菜单和播放器
- **验证码显示**(通过 CaptchaComponent.vue
- 把 Markdown 更新回父组件 - 把 Markdown 更新回父组件
### 设置状态 ### 设置状态
@@ -70,6 +80,7 @@
- utils/config.js 负责从 VITE_* 环境变量拼接接口地址。 - utils/config.js 负责从 VITE_* 环境变量拼接接口地址。
- 前端 API 默认基址必须是 `https://api.imageteach.tech:8002`;不要把前端请求导向 Docker 内后端、本机 `localhost:8001` / `localhost:8081`,也不要依赖 Docker nginx 的同源 `/v1` 代理。 - 前端 API 默认基址必须是 `https://api.imageteach.tech:8002`;不要把前端请求导向 Docker 内后端、本机 `localhost:8001` / `localhost:8081`,也不要依赖 Docker nginx 的同源 `/v1` 代理。
- utils/api.js 负责补全请求、取消补全、TTS 请求和状态请求。 - utils/api.js 负责补全请求、取消补全、TTS 请求和状态请求。
- **utils/fetch.js** 提供安全 fetch wrapper,自动附加 X-API-Key 认证头。
- fetchSuggestion 会: - fetchSuggestion 会:
- 生成 request_id - 生成 request_id
- 绑定 AbortSignal - 绑定 AbortSignal
@@ -91,6 +102,8 @@
- 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。 - 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。
- OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。 - OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。
- 前端存在 /v1/export/pdf 调用,但调试前先确认后端是否真的实现了这个端点。 - 前端存在 /v1/export/pdf 调用,但调试前先确认后端是否真的实现了这个端点。
- **Web Search 块使用 ```llm-websearch fenced code 语法,触发词为 [WEBSEARCH](不区分大小写)。**
- **验证码组件依赖 vue3-captcha 库,刷新和验证逻辑需保持与后端 /captcha/generate 和 /captcha/verify 接口同步。**
- 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。 - 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。
- 需要多语言 UI 时,优先走现有 i18n 结构,而不是在组件里新增硬编码文案。 - 需要多语言 UI 时,优先走现有 i18n 结构,而不是在组件里新增硬编码文案。
@@ -103,13 +116,23 @@
-> backend -> backend
- OCR 问题: - OCR 问题:
components/MilkdownEditor.vue components/MilkdownEditor.vue -> **OCRImageWrapper.vue**
-> OCR 请求 -> OCR 请求
-> backend/main.py -> backend/main.py
- Web Search 问题:
components/WebSearchBlockCrepe.vue
-> plugins/webSearchBlockPlugin.ts
-> utils/webSearch.js
-> backend/job_handlers.pyweb_search_handler
- 验证码问题:
components/CaptchaComponent.vue
-> backend/captcha_api.py/captcha/generate, /captcha/verify
- 文档导入转换问题: - 文档导入转换问题:
components/MilkdownEditor.vue components/MilkdownEditor.vue
-> utils/convert.js -> utils/convert.js、**utils/docsApi.js**
-> backend/main.py -> backend/main.py
- TTS 问题: - TTS 问题:
@@ -122,7 +145,8 @@
- 编辑器相关问题优先从 MilkdownEditor.vue 入手,不要先到处搜。 - 编辑器相关问题优先从 MilkdownEditor.vue 入手,不要先到处搜。
- 插件逻辑问题优先看 plugins/,尤其是 copilotPlugin.ts。 - 插件逻辑问题优先看 plugins/,尤其是 copilotPlugin.ts。
- 状态问题优先看 stores/settings.js。 - 状态问题优先看 stores/settings.js。
- 网络问题优先看 utils/config.js 和 utils/api.js。 - 网络问题优先看 utils/config.js、**utils/fetch.js** 和 utils/api.js。
- **新增组件问题优先看对应的 wrapper/componentOCRImageWrapper、WebSearchBlockCrepe、CaptchaComponent)。**
- 改前端时尽量避免同时改样式、文案、结构和网络逻辑,多做局部可验证修改。 - 改前端时尽量避免同时改样式、文案、结构和网络逻辑,多做局部可验证修改。
## 不要做的事 ## 不要做的事
+112 -3
View File
@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue' import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection' import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js' import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
@@ -35,8 +37,10 @@ const imageEditorError = ref('')
const isEditingImage = ref(false) const isEditingImage = ref(false)
const isSavingImage = ref(false) const isSavingImage = ref(false)
const videoPreviewError = ref('') const videoPreviewError = ref('')
const videoElementRef = ref(null)
const resolvedFileBlob = ref(null) const resolvedFileBlob = ref(null)
let blobRequestToken = 0 let blobRequestToken = 0
let plyrInstance = null
const isRoot = computed(() => !props.node) const isRoot = computed(() => !props.node)
const isFolder = computed(() => props.node?.type === 'folder') const isFolder = computed(() => props.node?.type === 'folder')
@@ -248,6 +252,79 @@ function handleVideoError() {
videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。' videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
} }
function initPlyrPlayer() {
if (!videoElementRef.value) return
// Destroy existing instance if any
if (plyrInstance) {
try {
plyrInstance.destroy()
} catch (e) {
console.warn('Failed to destroy Plyr instance:', e)
}
}
try {
plyrInstance = new Plyr(videoElementRef.value, {
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',
toggleControls: true,
resetOnEnd: true,
hideControls: true,
tooltips: {
controls: true,
seek: false
},
})
plyrInstance.on('error', () => {
handleVideoError()
})
} catch (error) {
console.error('Plyr initialization failed:', error)
videoPreviewError.value = '播放器初始化失败'
}
}
watch(
() => objectUrl.value,
(url) => {
if (url && isVideo.value) {
nextTick(() => {
initPlyrPlayer()
})
}
}
)
onBeforeUnmount(() => {
if (plyrInstance) {
try {
plyrInstance.destroy()
plyrInstance = null
} catch (e) {
console.warn('Failed to cleanup Plyr instance:', e)
}
}
})
async function copyText() { async function copyText() {
if (!previewText.value) return if (!previewText.value) return
try { try {
@@ -414,11 +491,12 @@ function downloadFile() {
<video <video
v-else-if="objectUrl" v-else-if="objectUrl"
class="video-player" ref="videoElementRef"
class="plyr-video video-player"
controls controls
playsinline playsinline
preload="auto"
:src="objectUrl" :src="objectUrl"
@error="handleVideoError"
></video> ></video>
</div> </div>
</div> </div>
@@ -834,6 +912,37 @@ function downloadFile() {
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22); box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
} }
/* Plyr-specific overrides */
.plyr {
--plyr-color-main: #0969da;
border-radius: 14px;
overflow: hidden;
}
.plyr--video .plyr__control.plyr__tab-focus {
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.5);
}
.plyr--video .plyr__control:hover:not(.plyr__tab-focus) {
background: #0969da;
}
.plyr--video .plyr__control--overlaid {
background: rgba(9, 105, 218, 0.9);
}
.plyr--full-ui.plyr--video .plyr__controls {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
}
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"] {
background: #0969da;
}
.plyr__volume {
color: #0969da;
}
.primary-btn { .primary-btn {
border-color: #0969da; border-color: #0969da;
background: #0969da; background: #0969da;
+5 -1
View File
@@ -386,12 +386,13 @@ const acceptAll = computed(() => {
'.docx', '.pptx', '.pdf', '.docx', '.pptx', '.pdf',
'.wav', '.mp3', '.m4a', '.ogg', '.flac', '.wav', '.mp3', '.m4a', '.ogg', '.flac',
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif',
'.mp4', '.webm', '.mov', '.avi', '.mkv',
'text/plain', 'application/json', 'text/plain', 'application/json',
'text/yaml', 'text/x-yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml', 'application/x-yaml',
'application/pdf', 'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'image/*', 'audio/*' 'image/*', 'audio/*', 'video/*'
] ]
return types.join(',') return types.join(',')
}) })
@@ -405,6 +406,7 @@ const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const VIDEO_EXT_RE = /\.(mp4|webm|mov|avi|mkv)$/i
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml']) const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
@@ -413,6 +415,8 @@ const CONVERT_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf', 'application/pdf',
]) ])
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska'])
const MAX_VIDEO_SIZE = 200 * 1024 * 1024 // 200MB
const MAX_UPLOAD_BATCH = 10 const MAX_UPLOAD_BATCH = 10
const MAX_UPLOAD_FILE_SIZE = 50 * 1024 * 1024 const MAX_UPLOAD_FILE_SIZE = 50 * 1024 * 1024
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value) let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
+14 -6
View File
@@ -44,14 +44,22 @@
<div class="tts-player__settings"> <div class="tts-player__settings">
<div class="tts-player__volume"> <div class="tts-player__volume">
<button class="tts-player__btn" @click="toggleMute" :title="isMuted ? '取消静音' : '静音'"> <button class="tts-player__btn" @click="toggleMute" :title="isMuted ? '取消静音' : '静音'">
<svg v-if="!isMuted && volume > 0.5" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 高音量扬声器图标 + 两道向外扩散的声波弧线 -->
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/> <svg v-if="!isMuted && volume > 0.5" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l18-4v14l-18-4z"/>
<path d="M7 12c0-1.7.9-3.2 2.3-4.1"/>
<path d="M7 12c0-4.4 3.1-8.1 7.4-9.4"/>
</svg> </svg>
<svg v-else-if="!isMuted" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 中音量扬声器图标 + 一道声波弧线 -->
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/> <svg v-else-if="volume > 0" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l18-4v14l-18-4z"/>
<path d="M7 12c0-1.7.9-3.2 2.3-4.1"/>
</svg> </svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 静音扬声器图标 + X形交叉线 -->
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/> <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l18-4v14l-18-4z"/>
<line x1="7" y1="5" x2="13" y2="19"/>
<line x1="13" y1="5" x2="7" y2="19"/>
</svg> </svg>
</button> </button>
<input type="range" class="tts-player__slider" v-model.number="volume" min="0" max="1" step="0.05" title="音量"> <input type="range" class="tts-player__slider" v-model.number="volume" min="0" max="1" step="0.05" title="音量">
+218
View File
@@ -0,0 +1,218 @@
<template>
<div class="video-player-container" :style="plyrThemeVars">
<video
ref="videoEl"
:src="videoUrl"
:type="mimeType"
controls
playsinline
preload="auto"
class="plyr-video"
>
您的浏览器不支持视频播放
</video>
<div v-if="isLoading" class="video-loading">
<p>正在加载视频...</p>
</div>
<Transition name="fade">
<div v-if="playerError" class="video-error">
<p> {{ playerError }}</p>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Plyr from 'plyr'
const props = defineProps({
videoUrl: { type: String, default: '' },
mimeType: { type: String, default: 'video/mp4' },
autoPlay: { type: Boolean, default: false }
})
const emit = defineEmits(['error', 'ready'])
const videoEl = ref(null)
const isLoading = ref(true)
const playerError = ref('')
let playerInstance = null
// Plyr 主题变量(匹配 GitHub 蓝色)
const plyrThemeVars = computed(() => ({
'--plyr-color-main': '#0969da',
'--plyr-control-opacity': '1',
'--plyr-video-control-color-hover': '#0969da',
}))
onMounted(() => {
if (!videoEl.value) return
try {
playerInstance = new Plyr(videoEl.value, {
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: props.autoPlay,
preload: 'auto',
muted: false,
toggleControls: true,
resetOnEnd: true,
hideControls: true,
tooltips: {
controls: true,
seek: false
},
})
// 监听播放器事件
playerInstance.on('ready', () => {
isLoading.value = false
emit('ready')
})
playerInstance.on('playing', () => {
isLoading.value = false
})
playerInstance.on('error', (error) => {
console.error('Plyr 播放器错误:', error)
playerError.value = '视频加载失败,请检查格式或网络'
emit('error', error)
})
playerInstance.on('loadeddata', () => {
isLoading.value = false
})
} catch (error) {
console.error('Plyr 初始化失败,回退到原生播放器:', error)
playerError.value = '播放器初始化失败,使用原生控件'
}
})
onUnmounted(() => {
if (playerInstance) {
try {
playerInstance.destroy()
} catch (error) {
console.error('销毁播放器失败:', error)
}
}
})
function handleVideoError(event) {
console.error('视频加载失败:', event.target.error)
playerError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
emit('error', event)
}
</script>
<style scoped>
.video-player-container {
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.plyr-video {
width: 100%;
height: auto;
max-height: 480px;
}
.video-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.video-loading p {
color: #666;
}
.video-error {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
background-color: #fff0f0;
color: #cf222e;
font-size: 13px;
}
.video-error p {
margin: 0;
}
/* Plyr 主题覆盖 */
:deep(.plyr) {
border-radius: 8px;
font-family: inherit;
}
:deep(.plyr--video .plyr__controls) {
border-top: none;
}
:deep(.plyr__control--overlaid) {
background: rgba(9, 105, 218, 0.8);
color: #fff;
}
:deep(.plyr--full-ui.plyr--video .plyr__control.plyr__tab-focus,
.plyr--full-ui.plyr--video .plyr__control:hover) {
background: #0969da;
}
/* 淡入动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<style>
/* 全局 Plyr 样式(非 scoped,用于控制条主题) */
.plyr--video .plyr__control.plyr__tab-focus {
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.5);
}
.plyr--video .plyr__control:hover:not(.plyr__tab-focus) {
background: #0969da;
}
.plyr--video .plyr__control--overlaid {
background: rgba(9, 105, 218, 0.9);
}
.plyr--video .plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"] {
background: #0969da;
}
.plyr__volume {
color: #0969da;
}
</style>
+21 -14
View File
@@ -30,16 +30,19 @@
</button> </button>
<template v-else-if="hasContent"> <template v-else-if="hasContent">
<button type="button" class="web-search-card__btn" title="压缩搜索结果" contenteditable="false" @mousedown.stop.prevent @click.stop="handleCompress"> <button type="button" class="web-search-card__btn" title="压缩搜索结果" contenteditable="false" @mousedown.stop.prevent @click.stop="handleCompress">
<svg v-if="compressState === 'idle' || compressState === 'completed'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 压缩完成/空闲上下箭头向中间挤压直观表达"压缩" -->
<path d="M4 14h6v7H4z"/> <svg v-if="compressState === 'idle' || compressState === 'completed'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 9h6v12h-6z"/> <polyline points="8 3 12 7 16 3"/>
<path d="M4 9h6v5H4z"/> <polyline points="8 21 12 17 16 21"/>
<line x1="12" y1="7" x2="12" y2="17"/>
</svg> </svg>
<span v-if="compressState === 'queued' || compressState === 'processing'" class="web-search-card__spinner"></span> <span v-if="compressState === 'queued' || compressState === 'processing'" class="web-search-card__spinner"></span>
<svg v-else-if="compressState === 'error'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 错误状态X形四向箭头表示"取消/错误" -->
<circle cx="12" cy="12" r="10"/> <svg v-else-if="compressState === 'error'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="15" y1="9" x2="9" y2="15"/> <line x1="12" y1="5" x2="12" y2="19"/>
<line x1="9" y1="9" x2="15" y2="15"/> <line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="9 8 12 5 15 8"/>
<polyline points="9 16 12 19 15 16"/>
</svg> </svg>
</button> </button>
<button type="button" class="web-search-card__btn" :title="collapsedState ? '展开结果' : '折叠结果'" contenteditable="false" @mousedown.stop.prevent @click.stop="toggleCollapse"> <button type="button" class="web-search-card__btn" :title="collapsedState ? '展开结果' : '折叠结果'" contenteditable="false" @mousedown.stop.prevent @click.stop="toggleCollapse">
@@ -52,12 +55,16 @@
</button> </button>
</template> </template>
<button type="button" class="web-search-card__btn web-search-card__btn--danger" title="删除搜索块" contenteditable="false" @mousedown.stop.prevent @click.stop="props.onDelete?.()"> <button type="button" class="web-search-card__btn web-search-card__btn--danger" title="删除搜索块" contenteditable="false" @mousedown.stop.prevent @click.stop="props.onDelete?.()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- 删除按钮标准垃圾桶图标梯形桶身 + 提手 + 两条竖线表示桶壁 -->
<path d="M3 6h18"/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 6V4h8v2"/> <polyline points="3 6 5 3 19 3 21 6"/>
<path d="M19 6l-1 14H6L5 6"/> <path d="M20 6v14c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1V6"/>
<path d="M10 11v6"/> <path d="M5 6l2 14"/>
<path d="M14 11v6"/> <path d="M17 6l-2 14"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="14" y1="9" x2="14.01" y2="9"/>
<line x1="9" y1="13" x2="9.01" y2="13"/>
<line x1="14" y1="13" x2="14.01" y2="13"/>
</svg> </svg>
</button> </button>
</div> </div>
+19 -64
View File
@@ -216,17 +216,13 @@ export function useFileSystem() {
return nextRecord return nextRecord
} }
// --- Async operations with unified error handling ---
async function load() { async function load() {
loading.value = true loading.value = true; records.value = []
try { try { records.value = await fetchDocNodes() }
records.value = await fetchDocNodes() catch (err) { error.value = err?.message || '读取文档空间失败,请稍后重试' }
error.value = null finally { loading.value = false }
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '读取文档空间失败,请稍后重试'
records.value = []
} finally {
loading.value = false
}
} }
async function createFile(parentId, name, content = '') { async function createFile(parentId, name, content = '') {
@@ -236,47 +232,21 @@ export function useFileSystem() {
} }
try { try {
const node = await createDocTextFile(name, parentId || null, content) const node = await createDocTextFile(name, parentId || null, content)
upsertRecord(node) upsertRecord(node); selectedId.value = node.id
if (parentId) { if (parentId) { expandedIds.value = new Set([...expandedIds.value, parentId]) }
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = node.id
error.value = null
return true return true
} catch (err) { } catch (err) { error.value = err?.message || '创建文件失败'; return false }
error.value = err instanceof Error && err.message ? err.message : '创建文件失败'
return false
}
} }
async function updateFile(id, nextValue, options = {}) { async function updateFile(id, nextValue, options = {}) {
const file = records.value.find((item) => item.id === id && item.type === 'file') const file = records.value.find((item) => item.id === id && item.type === 'file')
if (!file) return false if (!file) return false
try { try {
let node const node = nextValue instanceof Blob
if (nextValue instanceof Blob) { ? await replaceDocBlob(id, nextValue instanceof File ? nextValue : new File([nextValue], options.name || file.name, { type: options.mimeType || nextValue.type || '' }))
const filename = options.name || file.name : await updateDocNode(id, { name: options.name || file.name, content: String(options.content ?? nextValue) })
const upload = nextValue instanceof File upsertRecord(node); clearBlobCache(id); return true
? nextValue } catch (err) { error.value = err?.message || '保存文件失败'; return false }
: 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
}
} }
async function createFolder(parentId, name) { async function createFolder(parentId, name) {
@@ -286,19 +256,10 @@ export function useFileSystem() {
} }
try { try {
const node = await createDocFolder(name, parentId || null) const node = await createDocFolder(name, parentId || null)
upsertRecord(node) upsertRecord(node); selectedId.value = node.id
if (parentId) { if (parentId) expandedIds.value = new Set([...expandedIds.value, parentId])
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = node.id
error.value = null
return true return true
} catch (err) { } catch (err) { error.value = err?.message || '创建文件夹失败'; return false }
error.value = err instanceof Error && err.message ? err.message : '创建文件夹失败'
return false
}
} }
async function rename(id, newName) { async function rename(id, newName) {
@@ -306,14 +267,8 @@ export function useFileSystem() {
if (!node) return false if (!node) return false
try { try {
const updated = await updateDocNode(id, { name: newName }) const updated = await updateDocNode(id, { name: newName })
upsertRecord(updated) upsertRecord(updated); clearBlobCache(id); return true
clearBlobCache(id) } catch (err) { error.value = err?.message || '重命名失败'; return false }
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '重命名失败'
return false
}
} }
function collectDescendantIds(id) { function collectDescendantIds(id) {
+3 -8
View File
@@ -412,18 +412,13 @@ class WebSearchBlockNodeView implements NodeView {
return return
} }
this.setStage(event, String(data?.message || '')) this.setStage(event, String(data?.message || ''))
}, }, onDelta: (data) => {
onDelta: (data) => {
if (this.destroyed || this.requestSeq !== requestSeq) return if (this.destroyed || this.requestSeq !== requestSeq) return
const text = String(data?.text || '') const text = String(data?.text || '')
if (!text) return if (!text) return
streamedContent += text streamedContent += text
this.updateAttrs({ content: streamedContent } this.updateAttrs({ content: streamedContent })
return }, onDelta: (data) => {
}
this.setStage(event, String(data?.message || ''))
},
onDelta: (data) => {
if (this.destroyed || this.requestSeq !== requestSeq) return if (this.destroyed || this.requestSeq !== requestSeq) return
const text = String(data?.text || '') const text = String(data?.text || '')
if (!text) return if (!text) return
+52 -134
View File
@@ -2,165 +2,83 @@ import { defineStore } from 'pinia'
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { translations } from '../utils/i18n' 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', () => { export const useSettingsStore = defineStore('settings', () => {
// --- State --- // --- Reactive state (all auto-tracked by watch below) ---
const theme = ref('system') // 'light' | 'dark' | 'system'
// 1. Theme (handled partly by useTheme, but we keep a ref here for the UI) const modelThinking = ref('low') // 'low' | 'medium' | 'high'
const theme = ref('system') // 'light' | 'dark' | 'system' const debounceMs = ref(1000) // 1000 - 5000
const proThinking = ref('medium') // 'low' | 'medium' | 'high'
// 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
const privacyMode = ref(true) const privacyMode = ref(true)
// 4. Preferences
const language = ref('auto') const language = ref('auto')
const currency = ref('auto') const currency = ref('auto')
// 5. Background
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
// TTS Voice
const ttsInstruct = ref('') const ttsInstruct = ref('')
// --- Getters --- // --- Computed getters (derived from reactive state) ---
const uiLanguage = computed(() => { const uiLanguage = computed(() => {
if (language.value !== 'auto') { if (language.value !== 'auto') return language.value
return language.value
}
const sysLang = (navigator.language || navigator.userLanguage || 'en').split('-')[0] const sysLang = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const supported = ['zh', 'en', 'ja', 'ko', 'de', 'fr'] return ['zh', 'en', 'ja', 'ko', 'de', 'fr'].includes(sysLang) ? sysLang : 'en'
return supported.includes(sysLang) ? sysLang : 'en'
}) })
const detectedTimezone = computed(() => { const detectedTimezone = computed(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC')
return 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. // --- localStorage persistence ---
// 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
const loadSettings = () => { const loadSettings = () => {
try { try {
const stored = localStorage.getItem('llm-in-text-settings') const stored = localStorage.getItem(SETTINGS_KEY)
if (stored) { if (!stored) return
const data = JSON.parse(stored) const data = JSON.parse(stored)
if (data.theme) theme.value = data.theme // Restore each field with type guards
if (data.modelThinking) modelThinking.value = data.modelThinking if (data.theme) theme.value = data.theme
if (data.debounceMs) debounceMs.value = data.debounceMs if (data.modelThinking) modelThinking.value = data.modelThinking
if (data.proThinking) proThinking.value = data.proThinking if (data.debounceMs) debounceMs.value = data.debounceMs
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode if (data.proThinking) proThinking.value = data.proThinking
if (data.language) language.value = data.language if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.currency) currency.value = data.currency if (data.language) language.value = data.language
if (data.backgroundType) { if (data.currency) currency.value = data.currency
if (data.backgroundType === 'color') backgroundType.value = 'default' if (data.backgroundType === 'color') backgroundType.value = 'default'
else 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 } catch { /* use defaults */ }
}
} catch {
// Failed to load settings, use defaults
}
} }
// Save to localStorage
const saveSettings = () => { const saveSettings = () => {
try { try {
const data = { localStorage.setItem(SETTINGS_KEY, JSON.stringify({
theme: theme.value, theme: theme.value, modelThinking: modelThinking.value,
modelThinking: modelThinking.value, debounceMs: debounceMs.value, proThinking: proThinking.value,
debounceMs: debounceMs.value, privacyMode: privacyMode.value, language: language.value,
proThinking: proThinking.value, currency: currency.value, backgroundType: backgroundType.value,
privacyMode: privacyMode.value, backgroundImage: backgroundImage.value, backgroundOpacity: backgroundOpacity.value,
language: language.value,
currency: currency.value,
backgroundType: backgroundType.value,
backgroundImage: backgroundImage.value,
backgroundOpacity: backgroundOpacity.value,
ttsInstruct: ttsInstruct.value, ttsInstruct: ttsInstruct.value,
} }))
localStorage.setItem('llm-in-text-settings', JSON.stringify(data)) } catch { /* silently fail */ }
} catch {
// Failed to save settings
}
} }
// Reset to defaults
const resetSettings = () => { const resetSettings = () => {
theme.value = 'system' theme.value = 'system'; modelThinking.value = 'low'; debounceMs.value = 1000
modelThinking.value = 'low' proThinking.value = 'medium'; privacyMode.value = false; language.value = 'auto'
debounceMs.value = 1000 currency.value = 'auto'; backgroundType.value = 'default'; backgroundImage.value = ''
proThinking.value = 'medium' backgroundOpacity.value = 0.2; ttsInstruct.value = ''; saveSettings()
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 all reactive refs and auto-save on change
watch( watch([theme, modelThinking, debounceMs, proThinking, privacyMode, language, currency
[ , backgroundType, backgroundImage, backgroundOpacity, ttsInstruct], saveSettings)
theme,
modelThinking,
debounceMs,
proThinking,
privacyMode,
language,
currency,
backgroundType,
backgroundImage,
backgroundOpacity,
ttsInstruct,
],
() => {
saveSettings()
}
)
// Initialize loadSettings() // Initialize from localStorage
loadSettings()
return { return { theme, modelThinking, debounceMs, proThinking, privacyMode, language
theme, , currency, backgroundType, backgroundImage, backgroundOpacity, ttsInstruct
modelThinking, , detectedTimezone, uiLanguage, t, initialMarkdown, resetSettings }
debounceMs,
proThinking,
privacyMode,
language,
currency,
backgroundType,
backgroundImage,
backgroundOpacity,
ttsInstruct,
detectedTimezone,
uiLanguage,
t,
initialMarkdown,
resetSettings
}
}) })
+20
View File
@@ -9,6 +9,11 @@ export const DEFAULT_UPLOAD_BLOCK_TYPES = [
'toml', 'toml',
'yaml', 'yaml',
'images', 'images',
'mp4',
'webm',
'mov',
'avi',
'mkv',
] ]
const TYPE_ALIAS = { const TYPE_ALIAS = {
@@ -29,6 +34,11 @@ const TYPE_ALIAS = {
'[images]': 'images', '[images]': 'images',
image: 'images', image: 'images',
images: 'images', images: 'images',
mp4: 'mp4',
webm: 'webm',
mov: 'mov',
avi: 'avi',
mkv: 'mkv',
} }
const TYPE_LABELS = { const TYPE_LABELS = {
@@ -40,6 +50,11 @@ const TYPE_LABELS = {
toml: 'TOML', toml: 'TOML',
yaml: 'YAML', yaml: 'YAML',
images: '图片', images: '图片',
mp4: 'MP4',
webm: 'WebM',
mov: 'MOV',
avi: 'AVI',
mkv: 'MKV',
} }
const TYPE_ACCEPT_MAP = { const TYPE_ACCEPT_MAP = {
@@ -76,6 +91,11 @@ const TYPE_ACCEPT_MAP = {
'application/x-yaml', 'application/x-yaml',
], ],
images: ['image/*'], 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 const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
+101
View File
@@ -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',
}
}