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:
@@ -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_type=...>`)。解析链路必须保持完整,否则导入后无法自动复原。
|
||||
- **功能块导出序列化**: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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+89
-27
@@ -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/ 目录下的配置保持一致。**
|
||||
|
||||
Generated
+40
-3
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+31
-7
@@ -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)。**
|
||||
- 改前端时尽量避免同时改样式、文案、结构和网络逻辑,多做局部可验证修改。
|
||||
|
||||
## 不要做的事
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<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 Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
|
||||
|
||||
@@ -35,8 +37,10 @@ const imageEditorError = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const isSavingImage = ref(false)
|
||||
const videoPreviewError = ref('')
|
||||
const videoElementRef = ref(null)
|
||||
const resolvedFileBlob = ref(null)
|
||||
let blobRequestToken = 0
|
||||
let plyrInstance = null
|
||||
|
||||
const isRoot = computed(() => !props.node)
|
||||
const isFolder = computed(() => props.node?.type === 'folder')
|
||||
@@ -248,6 +252,79 @@ function handleVideoError() {
|
||||
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() {
|
||||
if (!previewText.value) return
|
||||
try {
|
||||
@@ -414,11 +491,12 @@ function downloadFile() {
|
||||
|
||||
<video
|
||||
v-else-if="objectUrl"
|
||||
class="video-player"
|
||||
ref="videoElementRef"
|
||||
class="plyr-video video-player"
|
||||
controls
|
||||
playsinline
|
||||
preload="auto"
|
||||
:src="objectUrl"
|
||||
@error="handleVideoError"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
@@ -834,6 +912,37 @@ function downloadFile() {
|
||||
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 {
|
||||
border-color: #0969da;
|
||||
background: #0969da;
|
||||
|
||||
@@ -386,12 +386,13 @@ const acceptAll = computed(() => {
|
||||
'.docx', '.pptx', '.pdf',
|
||||
'.wav', '.mp3', '.m4a', '.ogg', '.flac',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif',
|
||||
'.mp4', '.webm', '.mov', '.avi', '.mkv',
|
||||
'text/plain', 'application/json',
|
||||
'text/yaml', 'text/x-yaml', 'application/x-yaml',
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'image/*', 'audio/*'
|
||||
'image/*', 'audio/*', 'video/*'
|
||||
]
|
||||
return types.join(',')
|
||||
})
|
||||
@@ -405,6 +406,7 @@ const objectUrls = new Set()
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
const MARKDOWN_EXT_RE = /\.md$/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 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'])
|
||||
@@ -413,6 +415,8 @@ const CONVERT_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/pdf',
|
||||
])
|
||||
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska'])
|
||||
const MAX_VIDEO_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
const MAX_UPLOAD_BATCH = 10
|
||||
const MAX_UPLOAD_FILE_SIZE = 50 * 1024 * 1024
|
||||
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
|
||||
|
||||
@@ -44,14 +44,22 @@
|
||||
<div class="tts-player__settings">
|
||||
<div class="tts-player__volume">
|
||||
<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 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 v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||
<!-- 静音:扬声器图标 + X形交叉线 -->
|
||||
<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>
|
||||
</button>
|
||||
<input type="range" class="tts-player__slider" v-model.number="volume" min="0" max="1" step="0.05" title="音量">
|
||||
|
||||
@@ -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>
|
||||
@@ -30,16 +30,19 @@
|
||||
</button>
|
||||
<template v-else-if="hasContent">
|
||||
<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"/>
|
||||
<path d="M14 9h6v12h-6z"/>
|
||||
<path d="M4 9h6v5H4z"/>
|
||||
<!-- 压缩完成/空闲:上下箭头向中间挤压,直观表达"压缩" -->
|
||||
<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">
|
||||
<polyline points="8 3 12 7 16 3"/>
|
||||
<polyline points="8 21 12 17 16 21"/>
|
||||
<line x1="12" y1="7" x2="12" y2="17"/>
|
||||
</svg>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
<!-- 错误状态:X形四向箭头,表示"取消/错误" -->
|
||||
<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="12" y1="5" x2="12" y2="19"/>
|
||||
<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>
|
||||
</button>
|
||||
<button type="button" class="web-search-card__btn" :title="collapsedState ? '展开结果' : '折叠结果'" contenteditable="false" @mousedown.stop.prevent @click.stop="toggleCollapse">
|
||||
@@ -52,12 +55,16 @@
|
||||
</button>
|
||||
</template>
|
||||
<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"/>
|
||||
<path d="M8 6V4h8v2"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6"/>
|
||||
<path d="M14 11v6"/>
|
||||
<!-- 删除按钮:标准垃圾桶图标(梯形桶身 + 提手 + 两条竖线表示桶壁) -->
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 3 19 3 21 6"/>
|
||||
<path d="M20 6v14c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1V6"/>
|
||||
<path d="M5 6l2 14"/>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+52
-134
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user