Compare commits

...

8 Commits

Author SHA1 Message Date
“ydy0615” 356108e792 feat: add video OCR/ASR, DOCX/PDF export, input block and risk config updates
- Video pipeline: video file OCR via VLM plus audio track ASR, integrated
  into job_handlers with progress emit per phase. New media_utils.py for
  audio extraction from video files.
- Document export: richExport.js replaces inline docx builder; DOCX and PDF
  export buttons are now enabled in MilkdownEditor. File size limit raised to
  100 MB.
- Input block: new InputBlockCrepe.vue component with inputBlockPlugin.ts
  and inputBlock.js for custom user-input nodes in the editor.
- Risk config: added Vite dev server ports (5173) to CORS allowlist and
  increased OCR max input from 10 MB to 100 MB.
- TTS/ASR refactor: simplified tts_asr.py model loading and warmup logic.
- Test coverage: updated tests for llm, main endpoints, pro completions and
  web search modules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 16:32:31 +08:00
“ydy0615” 4813196b0a 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. 2026-06-13 11:03:40 +08:00
“ydy0615” 2283020e51 Refactor and enhance OCR and API functionalities
- Removed obsolete unit tests for TTS/ASR module.
- Deleted unused sample video file.
- Introduced OCRImageWrapper component for better OCR image handling with loading, success, and failure states.
- Updated copilot plugin to improve transaction handling and added new types for better type safety.
- Enhanced web search block plugin to support streaming content updates.
- Refactored API utility functions for better error handling and consistency across requests.
- Added new configuration for OCR API endpoint.
- Consolidated SSE event parsing into a shared utility.
- Created string utility functions to reduce code duplication.
- Removed outdated test documents related to compression functionality.
2026-06-10 14:47:51 +08:00
“ydy0615” 17d211bf93 feat: add web search block functionality and integrate with existing plugins
- Introduced a new web search block plugin to handle web search queries and results.
- Updated copilot, doc block, and pro block plugins to include web search context in AI completions.
- Implemented utility functions for parsing and building web search markdown.
- Enhanced API to support web search requests and responses.
- Added configuration for web search URL and timeout settings.
- Updated size limit checks to account for web search content.
2026-06-09 19:18:14 +08:00
“ydy0615” 5a26dfde2a refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更:
- 新增 risk_config.py: 风险配置数据类,支持环境变量驱动
- 新增 risk_control.py: 风险控制控制器,管理并发和预算
- 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID
- 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用
- 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性
- 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型
- main.py: 集成 middleware、risk/audit/session 模块 (+467/-7)
- job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4)
- llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1)
- job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4)
- pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4)
- prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0)
- tts_asr.py: asyncio loop 初始化,router export (+10/-0)

前端变更:
- src/components/CaptchaComponent.vue: 新增验证码组件 (NEW)
- src/utils/cookie_policy.js: Cookie 策略工具 (NEW)
- SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0)
- MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10)
- ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4)
- proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10)
- api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14)
- config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4)
- convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12)
- proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4)

配置和基础设施:
- docker-compose.yml: 新增端口映射 8001:8001 (+2/-0)
- docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6)
- vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4)
- .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1)
- backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0)
- pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2)
- .coveragerc: 移除 fail_under = 90 (+0/-1)
- .gitignore: 新增 docker-data/ (+3/-0)
- package.json: 新增 vue3-captcha 依赖 (+3/-1)
- AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5)
- public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1)

测试变更:
- test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4)
- test_main_cancel.py: 新增 reset 调用 (+6/-0)
- test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0)

总计: 45 个文件变更,+1009/-280 行
2026-06-08 11:51:39 +08:00
“ydy0615” b55af1eff0 feat: add Docker support and update backend dependencies
- Introduced `requirements.docker.txt` for Docker-specific dependencies.
- Updated `requirements.txt` to include `psycopg[binary]` and `python-multipart`.
- Enhanced test suite in `test_main_endpoints.py` to cover document CRUD operations.
- Modified `docker-compose.yml` to include PostgreSQL and frontend services.
- Added Nginx configuration for reverse proxying API requests.
- Refactored file handling in Vue components to support new document storage backend.
- Created new utility functions in `docsApi.js` for document management.
- Updated configuration to support new API endpoints for document operations.
- Adjusted Vite configuration to proxy API requests to the local backend.
2026-06-06 17:18:15 +08:00
“ydy0615” 81f711ef0b Migrate backend jobs to Redis Streams 2026-06-06 15:44:00 +08:00
“ydy0615” 2c7a02f587 Enhance LLM functionality with PRO mode support and improved prompt handling
- Added support for PRO mode in LLM with specific instruction handling and context awareness.
- Updated prompt building functions to include prefill options for better context management.
- Introduced new inline examples for PRO mode in JSON format.
- Enhanced system prompts to reflect PRO mode capabilities and rules.
- Modified API endpoints to accommodate new parameters and ensure backward compatibility.
- Improved test cases to validate new functionality and ensure comprehensive coverage.
2026-06-02 21:23:34 +08:00
152 changed files with 65773 additions and 8754 deletions
-1
View File
@@ -6,7 +6,6 @@ omit =
backend/__pycache__/*
[report]
fail_under = 90
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
htmlcov
.pytest_cache
.git
docker-data
backend/__pycache__
backend/tests/__pycache__
+18 -1
View File
@@ -1,4 +1,21 @@
VITE_API_BASE_URL=
VITE_API_BASE_URL=https://api.imageteach.tech:8002
VITE_API_URL=
VITE_OCR_URL=
VITE_CONVERT_URL=
VITE_PRO_URL=
VITE_TTS_URL=
VITE_TTS_STATUS_URL=
VITE_TTS_CONFIG_URL=
VITE_ASR_URL=
VITE_JOB_LOAD_URL=
VITE_DOCS_NODES_URL=
VITE_DOCS_FOLDERS_URL=
VITE_DOCS_TEXT_FILES_URL=
VITE_DOCS_UPLOAD_URL=
VITE_DOCS_BLOB_BASE_URL=
VITE_DOCS_NODES_BASE_URL=
VITE_PRO_FRONTEND_TIMEOUT_MS=3660000
VITE_API_KEY=
# Document block compression context limit (characters)
VITE_DOC_COMPRESS_CONTEXT_LIMIT=128000
+3
View File
@@ -54,3 +54,6 @@ api_performance_report.md
.omx/
.tmp-*.png
tmp-*.txt
# Docker runtime data must live under /Users/allenyuan/lit, never in this repo.
docker-data/
+129
View File
@@ -0,0 +1,129 @@
# 前端代码简化与优化提示词
## 目标
深入分析并简化前端代码结构,提升执行效率、可维护性和类型安全性。
## 优化原则
### 1. 代码简化
- **消除冗余**:识别并移除重复的逻辑、条件判断和错误处理模式
- **函数拆分**:将大型函数拆分为职责单一的小函数(每个函数只做一件事)
- **提取常量**:将魔法数字、字符串字面量提取为命名常量
- **减少嵌套**:使用早期返回(early return)替代深层 if/else 嵌套
### 2. 类型安全
- **明确类型**:为所有函数参数和返回值添加 TypeScript 类型注解
- **接口定义**:为复杂对象结构定义 interface,避免 `any` 类型
- **联合类型**:使用 discriminated unions 替代运行时 typeof 检查
### 3. 性能优化
- **懒加载**:对非核心模块使用动态 import()
- **防抖节流**:对频繁触发的事件(输入、滚动)添加 debounce/throttle
- **计算缓存**:对纯函数的重复计算结果进行 memoization
- **条件渲染**:使用 v-if/v-show 控制不必要的 DOM 操作
### 4. 错误处理
- **统一错误边界**:集中处理 fetch/API 调用异常
- **有意义错误信息**:避免空 catch,提供具体的失败原因
- **降级策略**:关键功能失败时有优雅的 fallback
### 5. 状态管理
- **最小化状态**:只存储必要的响应式数据
- **派生状态**:使用 computed 替代手动监听 + 条件赋值
- **作用域限制**:将状态定义在尽可能小的组件范围内
## 检查清单
### api.js 优化点
```typescript
// ❌ 问题:过多的条件分支和嵌套
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (/\/v1\/pro\/completions$/i.test(normalized)) { /*...*/ }
if (/\/v1\/web-search$/i.test(normalized)) { /*...*/ }
// ...
}
// ✅ 优化:使用映射表替代条件链
const CANCEL_PATH_MAP = {
'/v1/pro/completions': '/v1/pro/completions/cancel',
'/v1/web-search': '/v1/web-search/cancel',
'/v1/completions': '/v1/completions/cancel',
}
function getCancelUrl(apiUrl) {
const base = new URL(apiUrl).pathname.replace(/\/+$/, '')
return CANCEL_PATH_MAP[base] || `${base}/cancel`
}
```
### 通用模式识别
1. **重复的 fetch 包装**:提取统一的 `safeFetch()` 函数处理认证头、错误和超时
2. **SSE 解析器重复**:将 `parseSseEvent` 抽象为可复用的 stream 处理器
3. **设置状态访问**:避免在每次 API 调用时重新创建 settings store 实例
4. **条件类型检查**:用 TypeScript discriminated union 替代运行时 `typeof x === 'string'`
## 执行步骤
### 第一步:分析
1. 使用 `grep_search` 查找重复模式(相同的 if/else 块、try/catch
2. 使用 `semantic_search` 查找相似功能的不同实现
3. 识别高频调用的函数(API 请求、事件处理器)
### 第二步:重构
1. **提取纯函数**:将副作用(fetch、DOM操作)与数据处理分离
2. **创建工具库**:将通用逻辑移至 `src/utils/` 下的独立模块
3. **添加类型定义**:在 `src/plugins/types.ts` 中集中管理接口
4. **简化条件逻辑**:用策略模式或映射表替代 switch/if 链
### 第三步:验证
1. 运行 `npm run build` 确认无类型错误
2. 检查 `get_errors` 确保没有引入新问题
3. 手动测试关键路径(补全、OCR、上传)
## 输出格式
每次优化后提供:
```markdown
### 优化项: [函数名/文件名]
**问题**: [简要描述当前代码的问题]
**改动**:
```diff
- // 旧代码
+ // 新代码
```
**收益**:
- 行数减少: X%
- 时间复杂度: O(n) → O(1)
- 可读性提升: [具体说明]
```
## 示例调用
```bash
# 简化 api.js 中的 URL 处理逻辑
"简化 src/utils/api.js 中 getCancelUrl() 函数的条件分支,使用映射表替代正则匹配"
# 优化 copilotPlugin.ts 的类型定义
"为 src/plugins/copilotPlugin.ts 添加完整的 TypeScript 类型注解,消除所有 any 类型"
# 提取重复的错误处理
"将 src/utils/api.js 中分散的 try/catch 错误处理提取为统一的 errorBoundary() 高阶函数"
```
## 注意事项
- ✅ 保持向后兼容:不破坏现有 API 接口和事件流
- ✅ 小步快跑:每次只优化一个函数或模块,验证后再继续
- ❌ 避免过度优化:不要为了炫技引入复杂的函数式编程模式
- ⚠️ 测试覆盖:修改核心路径(补全、取消请求)前确保有对应测试
- 📝 文档同步:更新 CLAUDE.md 和 AGENTS.md 中的架构描述
## 参考文件
- `src/utils/api.js` - API 请求层,存在多处可简化的条件逻辑
- `src/plugins/copilotPlugin.ts` - 补全插件,类型定义不完整
- `src/stores/settings.js` - 状态管理,可优化响应式依赖
- `backend/prompt.py` - Prompt 组装逻辑(后端参考)
+94 -11
View File
@@ -1,4 +1,4 @@
# LLM in Text 仓库指引
# LLM in Text 仓库指引 (v0.2.0)
本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。
@@ -6,8 +6,25 @@
- 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。
- 前端技术栈:Vue 3 + Vite + Milkdown/Crepe + Pinia + Vue Router。
- 后端技术栈:FastAPI + Python + Ollama。
- 当前代码中可以确认的主功能是:AI 补全、OCR、文档转 Markdown、TTS、Markdown/DOCX/PDF 导入导出
- 后端技术栈:FastAPI + Python + Ollama-compatible LLM endpoint + Redis Streams
- 项目版本:v0.2.0(自 b82c6d3 之后的全栈架构升级版本)
## 功能块系统(核心概念)
- **统一命名**:文档块、PRO 块、上传块统称为"功能块"。
- **禁止嵌套**:所有功能块的 schema 均设 `atom: true, isolating: true`ProseMirror 层面强制禁止互相嵌套。DocBlockCrepe.vue 的嵌套 Crepe 编辑器不注册任何功能块插件,从架构上杜绝深层嵌套。
- **无数量限制**:一篇文档可包含任意数量的功能块,彼此独立存在。
- **导入自动解析**:从 Markdown 文件导入后,各功能块语法必须通过对应的 Remark/parseMarkdown 解析器自动识别并还原为交互卡片。
- **导出可复原**:通过 toMarkdown/leafText 序列化器将功能块还原为 Markdown 语法,确保导出后再次导入能完整复原。doc_block toMarkdown 输出 legacy HTML tag`getExportMarkdown()` 中通过 `transformLegacyDocBlocksForExport()` 转换为 fenced code block。
| 类型 | Node Type | Markdown 语法 | Plugin 文件 | Utility 文件 |
|------|-----------|---------------|-------------|--------------|
| 文档块 | `doc_block` | \`\`\`llm-file fenced code / `<doc_type=...>` legacy HTML tag | `plugins/docBlockPlugin.ts` | `utils/docBlock.js` |
| PRO 块 | `pro_block` | `[PRO]` / `[PRO]{指令}` | `plugins/proBlockPlugin.ts` | `utils/proBlock.js` |
| 上传块 | `upload_block` | `{{{}}}` / `{{{upload file type:...}}}` | `plugins/uploadBlockPlugin.ts` | `utils/uploadBlock.js` |
- **已验证**:当前代码完全符合"禁止嵌套、自动解析复原"的要求。修改功能块相关逻辑时需验证:1) schema 的 atom/isolating 属性不被移除;2) Remark/parseMarkdown/toMarkdown 解析链路完整。
- 当前代码中可以确认的主功能是:AI 补全、OCR、文档转 Markdown、TTS/ASR、Markdown/DOCX/PDF 导入导出。
- 历史文档中有一部分 TTS/ASR、Apple Silicon、Whisper、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。
## 先看哪里
@@ -16,25 +33,60 @@
- 前端入口: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.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
## 稳定事实
- 补全接口当前不是 SSE;前端用普通 POST 请求拿 JSON 响应
- **功能块禁止嵌套**`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_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。
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
- /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。
- 前端存在 /v1/export/pdf 调用点,但当前后端主路由中看不到同名端点;排查 PDF 导出问题前先确认服务端是否真正提供该接口
- 当前 tts_asr.py 主要提供 TTS 相关能力。不要直接沿用 README 或历史修复文档里关于 ASR、Whisper、MPS/offline 的描述
- **AI 开关是全局广播状态**MilkdownEditor.vue 通过 `llm-in-text:copilot-toggle` 同步主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;修 ghost text 时要同时检查这三处
- **设置项已从 currency 改为 country**:前后端请求、prompt、store、设置面板统一使用 `country`;仅在读取旧 localStorage 时兼容 `currency` 作为迁移兜底
- **DOCX/PDF 导出改为纯前端**:不再依赖 `/v1/export/pdf`。当前策略是先展开所有功能块,再从编辑器 HTML 构建导出内容;`src/utils/richExport.js` 负责 HTML -> PDF / DOCX。
- **上传单文件限制统一为 100MB**:前端校验和后端 OCR 风控上限都按 100MB 处理。
- **视频解析策略**:上传视频时,后端 `/v1/ocr` 接收 `media_type=video`,视频画面走 OCR 模型,音轨通过 ffmpeg 抽取后走 ASR 模型,最终合并为“视频画面 OCR + 视频音频 ASR”文本。
- **OCR 明确关闭思考**backend/llm.py 的 OCR payload 显式下发 `options.think = False``temperature = 0`
- **TTS/ASR 当前真实实现**backend/tts_asr.py 已切到 `Qwen3TTSModel + faster-whisper` 路线;不要继续按旧的 MLX-only 文档理解当前实现。
## 常用命令
@@ -51,6 +103,38 @@
- pytest backend/tests/test_prompt.py -v
- pytest backend/tests/test_llm.py -v
## Docker 部署约定
- 本机部署目录固定在 `/Users/allenyuan/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。
- 当前推荐的部署工作目录是 `/Users/allenyuan/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build`
- 不再使用 `/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 后端的代理。
- 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。
- 首选更新方式:
1. 在仓库根目录执行 `docker compose up -d --build`
2. 执行 `docker compose ps` 确认 `api``worker``frontend` 等目标服务已重新创建并处于 Up。
3. 对关键修复点执行容器内验证,例如 `docker compose exec -T worker sh -lc "python - <<'PY'\nfrom pathlib import Path\nprint('_normalize_preferences' in Path('/app/backend/prompt.py').read_text())\nPY"`
- 如果 `docker compose build` 因 Docker Hub、镜像源、网络 token 超时等外部原因无法拉基础镜像,仍然必须更新正在运行的 Docker 环境。可用应急方式:
1. 先执行 `npm run build` 生成最新前端产物。
2.`docker cp` 将改动后的后端文件复制到 `api``worker` 容器的 `/app/backend/`,必要时将 `dist/` 复制到 `frontend` 容器的 `/usr/share/nginx/html/`
3. 执行 `docker compose restart api worker frontend` 重启受影响服务。
4. 执行 `docker commit llm-in-text-api-1 llm-in-text-api:latest``docker commit llm-in-text-worker-1 llm-in-text-worker:latest`;如果更新了前端,也执行 `docker commit llm-in-text-frontend-1 llm-in-text-frontend:latest`
5. 执行 `docker compose up -d --no-build --force-recreate api worker frontend`,确保新容器来自已更新镜像。
6. 再次用 `docker compose exec -T ...` 验证容器内文件和行为,不能只看本地文件。
- Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。
- 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。
- 当前 Docker 部署的 `backend/requirements.docker.txt` 已包含 OCR、转换、队列以及 `torch` / `qwen-tts` / `faster-whisper`,并在 `backend/Dockerfile` 中额外安装 `ffmpeg` 以支持视频拆音轨。
- **Worker 容器**worker.py 作为独立服务运行,通过 Redis Streams 消费任务队列。修改 job_handlers.py 或 worker.py 后需要验证 worker 容器内的代码已更新,可通过 `docker compose exec -T worker sh -lc "python -c 'from backend.job_handlers import get_handler; print(get_handler(\"completion\").__name__)'"` 验证。
- **Redis Streams 架构**:任务队列使用 Redis Streams,支持并发控制、速率限制和熔断器。job_system.py 定义 JOB_TYPES 和队列配置,worker.py 注册处理器并运行事件循环。
- 修改 Docker 相关文件时,除了代码本身,还要同步检查:
- `docker-compose.yml`
- `backend/Dockerfile`
- `backend/requirements.docker.txt`
- `Dockerfile.frontend`
- `docker/nginx.conf`
- `backend/.env.example` 与实际部署用 `backend/.env`
## 代码约定
- 不要把整个仓库当成“全小写+短横线命名”项目。当前实际情况是:
@@ -98,4 +182,3 @@
- README.md 对产品功能有参考价值,但其中补全、TTS/ASR 和部分接口说明已经比代码旧。
- backend/TTS_ASR_MACOS_FIX.md 和 backend/tests/TESTING_GUIDE.md 更适合作为历史背景,不应在与代码冲突时被当成事实来源。
- 修改行为时,优先参考实现代码和对应测试,再决定是否同步普通文档。
+6 -1
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
ARG DOCKER_REGISTRY_PREFIX=
FROM ${DOCKER_REGISTRY_PREFIX}node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY index.html vite.config.js ./
COPY public ./public
COPY src ./src
RUN npm run build
FROM ${DOCKER_REGISTRY_PREFIX}nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+53 -5
View File
@@ -16,10 +16,18 @@
- 流式响应,低延迟体验
- 多种交互方式:Tab接受、Esc拒绝、点击接受
### 文档处理
- OCR 图片识别:上传图片自动识别文字
- 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
- 文档块嵌入:可折叠的文档预览块
### 功能块系统
编辑器提供三种**功能块**,统一为顶层原子节点(`atom: true, isolating: true`),通过 ProseMirror schema 强制禁止互相嵌套,无数量限制。导入 Markdown 后自动解析还原为交互卡片,导出后可完整复原:
| 功能块 | Markdown 语法 | 作用 |
|--------|---------------|------|
| **文档块** (`doc_block`) | \`\`\`llm-file fenced code / `<doc_type=...>` legacy HTML tag | 上传的 PDF/DOCX/PPTX/TXT 等文件以可折叠卡片嵌入编辑器,支持内联编辑和 AI 补全 |
| **PRO 块** (`pro_block`) | `[PRO]` / `[PRO]{指令}` | 基于全文上下文进行深度 AI 思考并流式生成 Markdown`Ctrl+Shift+P` 快速插入 |
| **上传块** (`upload_block`) | `{{{}}}` / `{{{upload file type:pdf,docx}}}` | 文件上传占位符,支持按类型过滤(PDF/DOCX/PPTX/TXT/JSON/YAML/图片等) |
### 文档处理(历史名称,已整合入功能块系统)
- OCR 图片识别:上传图片自动识别文字(OCR 结果注入 AI 补全和 PRO 块上下文)
- 智能大小限制:32KB自动禁用AI
### 设置面板
@@ -37,9 +45,17 @@
## 技术架构
前端: Vue3 + Vite + Milkdown + ProseMirror
前端: Vue3 + Vite + Milkdown/Crepe + ProseMirror
后端: FastAPI + PythonOpenAI 兼容端点)
### 功能块架构
三种功能块统一为顶层原子节点(`atom: true, isolating: true`),通过 ProseMirror schema 强制禁止嵌套:
- **文档块** (`doc_block`) — `src/plugins/docBlockPlugin.ts`Markdown 语法:\`\`\`llm-file fenced code block
- **PRO 块** (`pro_block`) — `src/plugins/proBlockPlugin.ts`Markdown 语法:`[PRO]` / `[PRO]{指令}`
- **上传块** (`upload_block`) — `src/plugins/uploadBlockPlugin.ts`Markdown 语法:`{{{}}}` / `{{{upload file type:...}}}`
每个功能块配备独立的 Remark 解析器和序列化器,确保 Markdown 导入导出时自动识别和还原。
## 快速开始
环境: Node.js 18+、Python 3.8+
@@ -52,12 +68,44 @@
- 后端: python backend/main.py (端口8001)
- 前端: npm run dev (端口5173)
## Docker 部署
将整个项目目录放进本机 `~/lit/llm-in-text` 后,在项目根目录执行:
```bash
cp backend/.env.example backend/.env
docker compose up -d --build
```
默认对外端口:
- 前端: `http://localhost:8080`
- 后端: `http://localhost:8001`
持久化目录全部位于当前项目下的 `docker-data/`
- PostgreSQL: `docker-data/postgres`
- Redis: `docker-data/redis`
- 任务共享临时目录: `docker-data/jobs`
部署前至少需要修改这些环境变量:
- `backend/.env` 中的 `LLM_BASE_URL`
- `backend/.env` 中的 `LLM_API_KEY`
- `backend/.env` 或 shell 环境中的 `DATABASE_URL`
- `backend/.env` 中的 `API_KEY`
## API接口
- POST /v1/completions 流式补全建议
- POST /v1/ocr 图片文字识别
- POST /v1/convert 文档转换
- POST /v1/completions/cancel 取消请求
- GET /v1/docs/nodes 文档空间节点列表
- POST /v1/docs/folders 创建文件夹
- POST /v1/docs/files/text 创建文本文件
- POST /v1/docs/files/upload 上传文件到文档空间
- PATCH /v1/docs/nodes/{id} 更新节点
- PUT /v1/docs/files/{id}/blob 替换文件二进制内容
- DELETE /v1/docs/nodes/{id} 删除节点
- GET /v1/docs/files/{id}/blob 下载或预览原文件
- GET /v1/tts-asr/status TTS/ASR模型状态
- GET /v1/tts-asr/config TTS/ASR配置信息
- POST /v1/tts-asr/warmup 模型预热
+113 -14
View File
@@ -1,26 +1,125 @@
# LLM provider (OpenAI-compatible endpoint)
LLM_BASE_URL=http://localhost:11434/v1/
# For Ollama, API key is not required but a placeholder is needed.
LLM_API_KEY=ollama
# OpenAI-compatible endpoint
# In Docker, use host.docker.internal instead of localhost for a model service on the host.
LLM_BASE_URL=https://api.openai.com/v1/
LLM_API_KEY=sk-your-key
# Default model for inline completions (e.g., gpt-oss:20b, qwen3:8b)
LLM_MODEL=gpt-oss:20b
# Default model for inline completions
LLM_MODEL=gpt-4.1-mini
# Pro-tier model (defaults to LLM_MODEL if unset)
PRO_LLM_MODEL=gpt-oss:20b
PRO_LLM_MODEL=gpt-4.1
# Vision model for OCR (e.g., qwen3-vl:30b, llava)
VLM_MODEL=qwen3-vl:30b
# Vision model for OCR
VLM_MODEL=gpt-4.1-mini
# API key for the FastAPI app (change in production)
API_KEY=your-secret-key-here
# PRO completion timeout (seconds)
PRO_COMPLETION_TIMEOUT=1200
# Browser origins allowed to send anonymous session cookies
CORS_ALLOW_ORIGINS=https://imageteach.tech,https://www.imageteach.tech,http://localhost:5173,http://127.0.0.1:5173
# Concurrency limits
STANDARD_CONCURRENCY_LIMIT=5
PRO_CONCURRENCY_LIMIT=20
# Anonymous session cookie
SESSION_COOKIE_NAME=llm_anonymous_session
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=none
SESSION_COOKIE_DOMAIN=
SESSION_COOKIE_PATH=/
SESSION_COOKIE_MAX_AGE_SECONDS=2592000
SESSION_ROTATION_SECONDS=86400
# Job backend
JOB_BACKEND=redis
REDIS_URL=redis://localhost:6379/0
DATABASE_URL=postgresql://llm_in_text:llm_in_text_change_me@localhost:5432/llm_in_text
DOCS_BACKEND=postgres
JOB_REDIS_PREFIX=llmtext:jobs
JOB_CONSUMER_NAME=
JOB_SHARED_TEMP_DIR=/tmp/llm-in-text-jobs
JOB_STATE_TTL_SECONDS=600
JOB_EVENT_TTL_SECONDS=600
JOB_EVENT_STREAM_MAXLEN=512
JOB_CANCEL_POLL_SECONDS=0.5
JOB_BUSY_NORMAL_THRESHOLD=0.25
JOB_BUSY_HIGH_THRESHOLD=0.75
JOB_BUSY_FULL_THRESHOLD=1.0
# Per-queue concurrency and capacity
JOB_COMPLETION_CONCURRENCY=2
JOB_COMPLETION_MAX_QUEUE=16
JOB_PRO_COMPLETION_CONCURRENCY=1
JOB_PRO_COMPLETION_MAX_QUEUE=8
JOB_WEB_SEARCH_CONCURRENCY=1
JOB_WEB_SEARCH_MAX_QUEUE=4
JOB_COMPRESS_CONCURRENCY=1
JOB_COMPRESS_MAX_QUEUE=8
JOB_OCR_CONCURRENCY=1
JOB_OCR_MAX_QUEUE=8
JOB_CONVERT_CONCURRENCY=1
JOB_CONVERT_MAX_QUEUE=8
JOB_TTS_CONCURRENCY=1
JOB_TTS_MAX_QUEUE=4
JOB_ASR_CONCURRENCY=1
JOB_ASR_MAX_QUEUE=4
# Timeouts (seconds)
LLM_COMPLETION_TIMEOUT=600
LLM_OCR_TIMEOUT=600
# Compression limit
DOC_COMPRESS_CONTEXT_LIMIT=128000
# Risk control
RISK_API_WINDOW_SECONDS=60
RISK_API_SOFT_LIMIT=90
RISK_API_HARD_LIMIT=180
RISK_LLM_WINDOW_SECONDS=600
RISK_LLM_SOFT_LIMIT=8
RISK_LLM_HARD_LIMIT=16
RISK_SESSION_CONCURRENCY_LIMIT=2
RISK_GLOBAL_CONCURRENCY_LIMIT=12
RISK_DAILY_BUDGET_GLOBAL_USD=20
RISK_DAILY_BUDGET_SESSION_USD=2
RISK_DAILY_BUDGET_IP_USD=5
RISK_SINGLE_REQUEST_MAX_COST_USD=0.8
RISK_DELAY_STEP_MS=2500
RISK_DELAY_CAP_MS=30000
RISK_MODEL_CIRCUIT_FAILURES=8
RISK_MODEL_CIRCUIT_TTL_SECONDS=300
RISK_ENFORCE_REDIS_FAIL_CLOSED=false
# Backend-controlled model policy
RISK_COMPLETION_MODEL=gpt-4.1-mini
RISK_PRO_MODEL=gpt-4.1
RISK_VISION_MODEL=gpt-4.1-mini
RISK_WEB_SEARCH_MODEL=gpt-4.1-mini
RISK_COMPLETION_MAX_INPUT_CHARS=24000
RISK_COMPLETION_MAX_OUTPUT_TOKENS=768
RISK_COMPLETION_TEMPERATURE=0.4
RISK_PRO_MAX_INPUT_CHARS=48000
RISK_PRO_MAX_OUTPUT_TOKENS=2048
RISK_PRO_TEMPERATURE=0.6
RISK_WEB_SEARCH_MAX_INPUT_CHARS=128000
RISK_WEB_SEARCH_MAX_OUTPUT_TOKENS=4096
RISK_WEB_SEARCH_TEMPERATURE=0.4
RISK_COMPRESS_MAX_INPUT_CHARS=128000
RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536
RISK_OCR_MAX_INPUT_BYTES=104857600
# Web search providers
SEARXNG_BASE_URL=http://searxng:8080
SEARXNG_RESULT_LIMIT=10
FIRECRAWL_BASE_URL=http://firecrawl:3002
FIRECRAWL_API_KEY=change-me
WEB_SEARCH_QUERY_COUNT=4
WEB_SEARCH_SELECTED_URL_LIMIT=10
# Estimated pricing for budget control
RISK_COMPLETION_INPUT_COST_PER_1K=0.0004
RISK_COMPLETION_OUTPUT_COST_PER_1K=0.0016
RISK_PRO_INPUT_COST_PER_1K=0.003
RISK_PRO_OUTPUT_COST_PER_1K=0.012
RISK_VISION_INPUT_COST_PER_1K=0.0008
RISK_VISION_OUTPUT_COST_PER_1K=0.0024
# Legacy fallback: if LLM_BASE_URL is not set, OLLAMA_HOST will be auto-converted to /v1/ path
#OLLAMA_HOST=http://localhost:11434
+96 -31
View File
@@ -1,16 +1,27 @@
# Backend 后端指引
# Backend 后端指引 (v0.2.0)
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
## 后端职责
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
- 对外提供补全、取消补全、OCR、文档转换和 TTS/ASR 相关接口。
- 组织 Prompt,上下文清洗,调用 Ollama 模型。
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
## 先看哪里
- 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
- 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。
- 成功时返回 JSONcontent 和 request_id。
- finally 中清理当前 request_id 对应任务。
- **通过 job_system.py 提交到 Redis Streams 队列,由 worker.py 消费。**
- **成功时返回 JSONcontent 和 request_id。**
### /v1/completions/cancel
@@ -49,9 +62,12 @@
### /v1/ocr
- 把 base64 图片解码成字节。
- 调用 call_vlm_ocr
- 返回识别文本和原始文件名
- 把 base64 媒体内容解码成字节。
- 支持 `media_type=image|video``mime_type`
- 图片直接调用 call_vlm_ocr
- 视频先把整段视频送入 OCR 模型,再用 ffmpeg 抽取音轨交给 ASR,最后合并文本。
- **结果通过 job_handlers.py ocr_handler 处理。**
- 返回识别文本、原始文件名,以及视频场景下的 `ocr_text` / `asr_text`
### /v1/convert
@@ -59,17 +75,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 请求通过 job_handlers.py tts_handler 处理。**
- **ASR 请求通过 job_handlers.py asr_handler 处理。**
- **当前实现是 `Qwen3TTSModel + faster-whisper`,不是旧的 edge-tts / macos-say / MLX-only 路线。**
## 开发命令
- 安装依赖: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 +127,60 @@
- 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_TYPESworker.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、思考级别)。
- **OCR 明确关闭思考**llm.py 的 `call_vlm_ocr` 对 OCR 请求显式设置 `options.think = False``temperature = 0`
- **补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。**
- **ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。**
- **`/v1/export/pdf` 不是当前主链路**;DOCX/PDF 导出已经转到前端 `src/utils/richExport.js`
- **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
- GeoIPtests/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/ 目录下的配置保持一致。**
+20
View File
@@ -0,0 +1,20 @@
ARG DOCKER_REGISTRY_PREFIX=
FROM ${DOCKER_REGISTRY_PREFIX}python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.docker.txt /tmp/requirements.docker.txt
RUN pip install --no-cache-dir -r /tmp/requirements.docker.txt
COPY backend /app/backend
EXPOSE 8001
CMD ["python", "main.py"]
+1
View File
@@ -0,0 +1 @@
"""Backend package marker for tests and patch targets."""
+220
View File
@@ -0,0 +1,220 @@
import json
import os
import threading
from typing import Any
try:
import psycopg
except Exception: # pragma: no cover
psycopg = None
class BaseAuditStore:
def record_api_request(self, payload: dict[str, Any]) -> None:
return None
def record_llm_call(self, payload: dict[str, Any]) -> None:
return None
def upsert_daily_usage(self, payload: dict[str, Any]) -> None:
return None
class NullAuditStore(BaseAuditStore):
pass
class PostgresAuditStore(BaseAuditStore):
def __init__(self, database_url: str) -> None:
if psycopg is None:
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL 审计存储")
self.database_url = database_url
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
return psycopg.connect(self.database_url, autocommit=True)
def _ensure_initialized(self) -> None:
if self._initialized:
return
with self._init_lock:
if self._initialized:
return
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS api_request_audit (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
route TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
decision TEXT NOT NULL,
delay_ms INTEGER NOT NULL DEFAULT 0,
queue_ms INTEGER NOT NULL DEFAULT 0,
error_code TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS llm_call_audit (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
job_type TEXT NOT NULL,
model TEXT NOT NULL,
estimated_input_tokens INTEGER NOT NULL DEFAULT 0,
max_output_tokens INTEGER NOT NULL DEFAULT 0,
estimated_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
actual_output_chars INTEGER NOT NULL DEFAULT 0,
actual_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_code TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS risk_events (
id BIGSERIAL PRIMARY KEY,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daily_budget_usage (
usage_day DATE NOT NULL,
scope TEXT NOT NULL,
scope_hash TEXT NOT NULL,
estimated_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
actual_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
request_count INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (usage_day, scope, scope_hash)
)
"""
)
self._initialized = True
def record_api_request(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
metadata = payload.get("metadata") or {}
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO api_request_audit (
request_id, session_hash, ip_hash, route, method, status_code,
decision, delay_ms, queue_ms, error_code, metadata_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
payload["request_id"],
payload["session_hash"],
payload["ip_hash"],
payload["route"],
payload["method"],
int(payload["status_code"]),
payload["decision"],
int(payload.get("delay_ms", 0)),
int(payload.get("queue_ms", 0)),
payload.get("error_code", ""),
json.dumps(metadata, ensure_ascii=False),
),
)
def record_llm_call(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
metadata = payload.get("metadata") or {}
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO llm_call_audit (
request_id, session_hash, ip_hash, job_type, model,
estimated_input_tokens, max_output_tokens, estimated_cost,
actual_output_chars, actual_cost, status, error_code, metadata_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
payload["request_id"],
payload["session_hash"],
payload["ip_hash"],
payload["job_type"],
payload["model"],
int(payload.get("estimated_input_tokens", 0)),
int(payload.get("max_output_tokens", 0)),
float(payload.get("estimated_cost", 0.0)),
int(payload.get("actual_output_chars", 0)),
float(payload.get("actual_cost", 0.0)),
payload["status"],
payload.get("error_code", ""),
json.dumps(metadata, ensure_ascii=False),
),
)
def upsert_daily_usage(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO daily_budget_usage (
usage_day, scope, scope_hash, estimated_cost, actual_cost, request_count
)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (usage_day, scope, scope_hash)
DO UPDATE SET
estimated_cost = daily_budget_usage.estimated_cost + EXCLUDED.estimated_cost,
actual_cost = daily_budget_usage.actual_cost + EXCLUDED.actual_cost,
request_count = daily_budget_usage.request_count + EXCLUDED.request_count,
updated_at = CURRENT_TIMESTAMP
""",
(
payload["usage_day"],
payload["scope"],
payload["scope_hash"],
float(payload.get("estimated_cost", 0.0)),
float(payload.get("actual_cost", 0.0)),
int(payload.get("request_count", 0)),
),
)
_audit_store: BaseAuditStore | None = None
def get_audit_store(database_url: str | None = None) -> BaseAuditStore:
global _audit_store
if _audit_store is not None:
return _audit_store
if database_url or os.getenv("DATABASE_URL"):
_audit_store = PostgresAuditStore(database_url or os.getenv("DATABASE_URL", ""))
else:
_audit_store = NullAuditStore()
return _audit_store
def reset_audit_store() -> None:
global _audit_store
_audit_store = None
+256
View File
@@ -0,0 +1,256 @@
"""
验证码和 Cookie 策略管理模块
提供功能:
1. 图形验证码生成与验证 API
2. 现代 Cookie 策略管理 (HttpOnly, Secure, SameSite)
3. 验证码结果持久化到 Cookie
"""
import random
import string
import json
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/captcha", tags=["验证码"])
# ==================== 数据模型 ====================
class CaptchaConfig:
"""验证码配置"""
LENGTH = 6 # 验证码长度
CHARSET = string.ascii_letters + string.digits # 字符集: 大小写字母+数字
EXPIRE_SECONDS = 3600 # 过期时间: 1小时
class CaptchaResult:
"""验证码结果"""
def __init__(self, text: str):
self.text = text
self.created_at = int(__import__('time').time())
@property
def is_expired(self) -> bool:
now = int(__import__('time').time())
return (now - self.created_at) > CaptchaConfig.EXPIRE_SECONDS
# ==================== 全局状态 ====================
# 内存中的验证码存储 (生产环境建议用 Redis)
_active_captchas: dict[str, CaptchaResult] = {}
# ==================== 验证码 API ====================
@router.get("/generate", summary="生成新验证码")
async def generate_captcha(
response: Response,
use_cookie: bool = False, # 是否通过 Cookie 传递验证码文本
length: int = CaptchaConfig.LENGTH,
):
"""
生成新的验证码
- **use_cookie**: 是否同时设置 Cookie (方便前端读取)
- **length**: 验证码长度 (4-10)
返回:
- **request_id**: 验证码请求 ID
- **expires_in**: 过期时间(秒)
"""
# 生成随机字符串
chars = CaptchaConfig.CHARSET
captcha_text = ''.join(random.choices(chars, k=length))
# 存储到内存
request_id = f"captcha_{int(__import__('time').time() * 1000)}"
_active_captchas[request_id] = CaptchaResult(captcha_text)
# 如果请求使用 Cookie,设置 HttpOnly Cookie
if use_cookie:
response.set_cookie(
key="llm_captcha_text",
value=captcha_text,
max_age=CaptchaConfig.EXPIRE_SECONDS,
httponly=False, # 允许前端读取
secure=False, # HTTP/HTTPS 都适用
samesite="Lax", # 防止 CSRF
domain=".imageteach.tech",
path="/"
)
return {
"request_id": request_id,
"expires_in": CaptchaConfig.EXPIRE_SECONDS,
"cookie_set": use_cookie
}
@router.post("/validate", summary="验证用户输入的验证码")
async def validate_captcha(
request: Request,
user_input: str,
request_id: Optional[str] = None,
):
"""
验证用户输入的验证码
- **user_input**: 用户输入的验证码文本
- **request_id**: 可选,指定验证哪个验证码
返回:
- **is_valid**: 是否验证成功
- **submitted**: 用户提交的文本
"""
if not user_input:
raise HTTPException(status_code=400, detail="缺少验证码输入")
# 从请求头或 Cookie 获取 request_id
rid = request_id or request.headers.get("X-Captcha-Request-Id")
if not rid or rid not in _active_captchas:
raise HTTPException(
status_code=404,
detail="未找到验证码,请先生成"
)
captcha_result = _active_captchas[rid]
# 检查是否过期
if captcha_result.is_expired:
del _active_captchas[rid]
raise HTTPException(
status_code=410, # Gone
detail="验证码已过期,请重新生成"
)
# 不区分大小写比较
is_valid = captcha_result.text.lower() == user_input.strip().lower()
# 验证成功后删除该验证码 (一次性使用)
if is_valid:
del _active_captchas[rid]
return {
"is_valid": is_valid,
"submitted": user_input,
"matched": is_valid
}
@router.delete("/clear", summary="清除验证码 Cookie")
async def clear_captcha_cookie(response: Response):
"""清除所有验证码相关的 Cookie"""
response.delete_cookie(key="llm_captcha_text")
response.delete_cookie(key="llm_captcha_result")
return {"message": "验证码 Cookie 已清除"}
# ==================== Cookie 策略工具类 ====================
class CookiePolicy:
"""
现代 Cookie 策略管理器
支持的属性:
- **HttpOnly**: 防止 XSS 读取 Cookie
- **Secure**: 仅 HTTPS 传输 (当前设为 False 以支持 HTTP)
- **SameSite**: Lax/Strict/None (控制跨域行为)
- **Domain**: 指定域名 (.imageteach.tech)
- **Path**: 路径 (/)
- **Max-Age**: 过期时间 (秒)
"""
# 默认 Cookie 配置
DEFAULT_CONFIG = {
"llm_session": {
"max_age": 86400 * 7, # 7天
"httponly": True, # 防止 XSS
"secure": False, # HTTP/HTTPS 都适用
"samesite": "Lax", # 防止 CSRF
"domain": ".imageteach.tech",
"path": "/"
},
"llm_captcha": {
"max_age": 3600, # 1小时
"httponly": False,
"secure": False,
"samesite": "Lax",
"domain": ".imageteach.tech",
"path": "/"
},
"llm_preferences": {
"max_age": 86400 * 30, # 30天
"httponly": False,
"secure": True,
"samesite": "None", # 跨域场景
"domain": ".imageteach.tech",
"path": "/"
}
}
@classmethod
def set_cookie(cls, response: Response, name: str, value: str, override: dict = None):
"""
设置 Cookie
Args:
response: FastAPI Response 对象
name: Cookie 名称
value: Cookie 值
override: 可选的覆盖配置
"""
config = cls.DEFAULT_CONFIG.get(name, {})
if override:
config.update(override)
response.set_cookie(
key=name,
value=value,
max_age=config.get("max_age", 3600),
httponly=config.get("httponly", False),
secure=config.get("secure", False),
samesite=config.get("samesite", "Lax"),
domain=config.get("domain", ".imageteach.tech"),
path=config.get("path", "/")
)
@classmethod
def get_cookie_config(cls, name: str) -> dict:
"""获取 Cookie 配置"""
return cls.DEFAULT_CONFIG.get(name, {})
# ==================== 前端可用的 API ====================
@router.get("/cookies/list", summary="列出所有验证码相关 Cookie")
async def list_captcha_cookies(request: Request):
"""返回当前请求携带的所有验证码相关 Cookie"""
cookies = {
k: v for k, v in request.cookies.items()
if k.startswith("llm_")
}
return {
"cookies": cookies,
"has_captcha": "llm_captcha_text" in cookies,
"has_session": "llm_session" in cookies
}
@router.post("/cookies/set", summary="设置测试 Cookie")
async def set_test_cookie(
response: Response,
cookie_name: str = "llm_test",
cookie_value: str = "test_value"
):
"""设置一个测试用的 Cookie"""
CookiePolicy.set_cookie(response, cookie_name, cookie_value)
return {
"message": f"Cookie '{cookie_name}' 已设置",
"name": cookie_name,
"value": cookie_value
}
+668
View File
@@ -0,0 +1,668 @@
import mimetypes
import os
import threading
import uuid
from dataclasses import dataclass
from typing import Any
try: # pragma: no cover - optional in some test paths
import psycopg
from psycopg.rows import dict_row
except Exception: # pragma: no cover
psycopg = None
dict_row = None
MAX_TEXT_SIZE = 8 * 1024 * 1024
PREVIEW_TEXT_SIZE = 2 * 1024 * 1024
TEXT_EXTENSIONS = {
"md", "markdown", "txt", "json", "js", "jsx", "ts", "tsx",
"css", "scss", "less", "html", "htm", "py", "vue", "xml",
"yaml", "yml", "csv", "log", "sql", "toml", "ini", "cfg",
"conf", "sh", "bat", "ps1", "java", "c", "cpp", "h", "hpp",
"go", "rs", "swift", "kt", "rb", "php", "pl", "r", "scala",
"gradle", "properties", "env", "gitignore", "dockerfile",
}
BINARY_EXTENSIONS = {
"exe", "dll", "so", "dylib", "bin", "dat", "obj", "o", "a",
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
"pdf", "zip", "rar", "7z", "tar", "gz", "bz2", "xz",
"png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg",
"mp3", "mp4", "wav", "avi", "mov", "mkv", "flv", "wmv",
"ttf", "otf", "woff", "woff2", "eot",
"class", "pyc", "pyo", "jar", "war", "ear",
"db", "sqlite", "mdb", "accdb",
"pem", "key", "crt", "cer", "p12", "pfx", "jks",
"msg", "eml", "pst", "ost",
"dwg", "dxf", "step", "stl", "fbx", "3ds", "blend",
}
DEFAULT_MIME_TYPES = {
"avi": "video/x-msvideo",
"md": "text/markdown",
"markdown": "text/markdown",
"mkv": "video/x-matroska",
"mov": "video/quicktime",
"mp4": "video/mp4",
"txt": "text/plain",
"json": "application/json",
"js": "text/javascript",
"jsx": "text/javascript",
"ts": "text/typescript",
"tsx": "text/typescript",
"css": "text/css",
"html": "text/html",
"htm": "text/html",
"py": "text/x-python",
"vue": "text/plain",
"xml": "application/xml",
"yaml": "text/yaml",
"yml": "text/yaml",
"csv": "text/csv",
"log": "text/plain",
"sql": "text/plain",
"toml": "text/plain",
"ini": "text/plain",
"cfg": "text/plain",
"conf": "text/plain",
"sh": "text/plain",
"bat": "text/plain",
"ps1": "text/plain",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"flv": "video/x-flv",
"m4v": "video/x-m4v",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
"pdf": "application/pdf",
"ogg": "video/ogg",
"ogv": "video/ogg",
"webm": "video/webm",
"wmv": "video/x-ms-wmv",
}
def _now_sql() -> str:
return "CURRENT_TIMESTAMP"
def get_extension(name: str = "") -> str:
value = str(name or "")
if "." not in value:
return value.lower() if value.lower() == "dockerfile" else ""
return value.rsplit(".", 1)[-1].lower()
def infer_mime_type(name: str, fallback: str = "") -> str:
if fallback:
return fallback
ext = get_extension(name)
guessed = DEFAULT_MIME_TYPES.get(ext)
if guessed:
return guessed
guessed, _ = mimetypes.guess_type(name)
return guessed or "application/octet-stream"
def is_text_file(name: str, mime_type: str = "") -> bool:
ext = get_extension(name)
if ext in BINARY_EXTENSIONS:
return False
mime_value = str(mime_type or "").lower()
return ext in TEXT_EXTENSIONS or mime_value.startswith("text/") or "json" in mime_value or "xml" in mime_value
def _text_payload(text: str) -> dict[str, Any]:
encoded = text.encode("utf-8")
preview = encoded[:PREVIEW_TEXT_SIZE].decode("utf-8", errors="ignore")
return {
"storage_kind": "text",
"size": len(encoded),
"content_text": text,
"preview_text": preview if len(encoded) > PREVIEW_TEXT_SIZE else text,
"is_truncated_preview": len(encoded) > PREVIEW_TEXT_SIZE,
"blob_data": None,
}
def prepare_file_payload(name: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
resolved_mime = infer_mime_type(name, mime_type)
if is_text_file(name, resolved_mime):
text = raw_bytes.decode("utf-8", errors="ignore")
return {
"mime_type": resolved_mime,
**_text_payload(text),
}
return {
"mime_type": resolved_mime,
"storage_kind": "blob",
"size": len(raw_bytes),
"content_text": "",
"preview_text": "",
"is_truncated_preview": False,
"blob_data": raw_bytes,
}
@dataclass
class BlobPayload:
content: bytes
mime_type: str
filename: str
class BaseDocumentStore:
_UNSET = object()
def list_nodes(self) -> list[dict[str, Any]]:
raise NotImplementedError
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
raise NotImplementedError
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
raise NotImplementedError
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
raise NotImplementedError
def update_node(self, node_id: str, *, name: str | None | object = _UNSET, parent_id: str | None | object = _UNSET, content: str | None | object = _UNSET) -> dict[str, Any]:
raise NotImplementedError
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
raise NotImplementedError
def delete_node(self, node_id: str) -> None:
raise NotImplementedError
def get_blob(self, node_id: str) -> BlobPayload:
raise NotImplementedError
class InMemoryDocumentStore(BaseDocumentStore):
def __init__(self) -> None:
self.nodes: dict[str, dict[str, Any]] = {}
def _serialize(self, node: dict[str, Any]) -> dict[str, Any]:
return {
"id": node["id"],
"parentId": node["parent_id"],
"type": node["type"],
"name": node["name"],
"mimeType": node.get("mime_type") or "",
"storageKind": node.get("storage_kind") or "text",
"size": int(node.get("size") or 0),
"content": node.get("content_text") or "",
"previewText": node.get("preview_text") or "",
"isTruncatedPreview": bool(node.get("is_truncated_preview")),
"createdAt": int(node["created_at"]),
"updatedAt": int(node["updated_at"]),
}
def _timestamp(self) -> int:
import time
return int(time.time() * 1000)
def _get(self, node_id: str) -> dict[str, Any]:
node = self.nodes.get(node_id)
if node is None:
raise KeyError(node_id)
return node
def list_nodes(self) -> list[dict[str, Any]]:
ordered = sorted(
self.nodes.values(),
key=lambda item: (item["type"] != "folder", item["name"].lower(), item["created_at"]),
)
return [self._serialize(node) for node in ordered]
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
now = self._timestamp()
node = {
"id": str(uuid.uuid4()),
"parent_id": parent_id,
"type": "folder",
"name": name,
"mime_type": "",
"storage_kind": "text",
"size": 0,
"content_text": "",
"preview_text": "",
"is_truncated_preview": False,
"blob_data": None,
"created_at": now,
"updated_at": now,
}
self.nodes[node["id"]] = node
return self._serialize(node)
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
now = self._timestamp()
payload = _text_payload(content)
node = {
"id": str(uuid.uuid4()),
"parent_id": parent_id,
"type": "file",
"name": name,
"mime_type": infer_mime_type(name),
**payload,
"created_at": now,
"updated_at": now,
}
self.nodes[node["id"]] = node
return self._serialize(node)
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
now = self._timestamp()
payload = prepare_file_payload(name, raw_bytes, mime_type)
node = {
"id": str(uuid.uuid4()),
"parent_id": parent_id,
"type": "file",
"name": name,
**payload,
"created_at": now,
"updated_at": now,
}
self.nodes[node["id"]] = node
return self._serialize(node)
def update_node(self, node_id: str, *, name: str | None | object = BaseDocumentStore._UNSET, parent_id: str | None | object = BaseDocumentStore._UNSET, content: str | None | object = BaseDocumentStore._UNSET) -> dict[str, Any]:
node = self._get(node_id)
if name is not BaseDocumentStore._UNSET:
node["name"] = name
if node["type"] == "file":
node["mime_type"] = infer_mime_type(name, node.get("mime_type") or "")
if parent_id is not BaseDocumentStore._UNSET:
node["parent_id"] = parent_id
if content is not BaseDocumentStore._UNSET:
payload = _text_payload(content)
node.update(payload)
node["updated_at"] = self._timestamp()
return self._serialize(node)
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
node = self._get(node_id)
payload = prepare_file_payload(filename, raw_bytes, mime_type)
node.update(payload)
node["name"] = filename
node["mime_type"] = payload["mime_type"]
node["updated_at"] = self._timestamp()
return self._serialize(node)
def delete_node(self, node_id: str) -> None:
descendants = {node_id}
changed = True
while changed:
changed = False
for node in list(self.nodes.values()):
if node.get("parent_id") in descendants and node["id"] not in descendants:
descendants.add(node["id"])
changed = True
for current_id in descendants:
self.nodes.pop(current_id, None)
def get_blob(self, node_id: str) -> BlobPayload:
node = self._get(node_id)
if node["type"] != "file":
raise FileNotFoundError(node_id)
if node.get("blob_data") is not None:
content = node["blob_data"]
else:
content = (node.get("content_text") or "").encode("utf-8")
return BlobPayload(
content=content,
mime_type=node.get("mime_type") or infer_mime_type(node.get("name") or ""),
filename=node["name"],
)
class PostgresDocumentStore(BaseDocumentStore):
def __init__(self, database_url: str) -> None:
if psycopg is None or dict_row is None:
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL 文档存储")
self.database_url = database_url
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
return psycopg.connect(self.database_url, autocommit=True, row_factory=dict_row)
def _ensure_initialized(self) -> None:
if self._initialized:
return
with self._init_lock:
if self._initialized:
return
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS document_nodes (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES document_nodes(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('folder', 'file')),
name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT '',
storage_kind TEXT NOT NULL DEFAULT 'text',
size BIGINT NOT NULL DEFAULT 0,
content_text TEXT NOT NULL DEFAULT '',
preview_text TEXT NOT NULL DEFAULT '',
is_truncated_preview BOOLEAN NOT NULL DEFAULT FALSE,
blob_data BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS document_nodes_parent_idx ON document_nodes(parent_id)"
)
self._initialized = True
def _serialize(self, row: dict[str, Any]) -> dict[str, Any]:
created = row["created_at"]
updated = row["updated_at"]
return {
"id": row["id"],
"parentId": row["parent_id"],
"type": row["type"],
"name": row["name"],
"mimeType": row.get("mime_type") or "",
"storageKind": row.get("storage_kind") or "text",
"size": int(row.get("size") or 0),
"content": row.get("content_text") or "",
"previewText": row.get("preview_text") or "",
"isTruncatedPreview": bool(row.get("is_truncated_preview")),
"createdAt": int(created.timestamp() * 1000),
"updatedAt": int(updated.timestamp() * 1000),
}
def _fetch_node(self, node_id: str) -> dict[str, Any]:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM document_nodes WHERE id = %s", (node_id,))
row = cur.fetchone()
if row is None:
raise KeyError(node_id)
return row
def list_nodes(self) -> list[dict[str, Any]]:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
id,
parent_id,
type,
name,
mime_type,
storage_kind,
size,
CASE
WHEN storage_kind = 'text' AND size <= %s THEN content_text
ELSE ''
END AS content_text,
preview_text,
is_truncated_preview,
created_at,
updated_at
FROM document_nodes
ORDER BY
CASE WHEN type = 'folder' THEN 0 ELSE 1 END,
LOWER(name),
created_at
""",
(MAX_TEXT_SIZE,),
)
return [self._serialize(row) for row in cur.fetchall()]
def create_folder(self, name: str, parent_id: str | None) -> dict[str, Any]:
self._ensure_initialized()
node_id = str(uuid.uuid4())
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO document_nodes (id, parent_id, type, name, updated_at)
VALUES (%s, %s, 'folder', %s, CURRENT_TIMESTAMP)
RETURNING *
""",
(node_id, parent_id, name),
)
return self._serialize(cur.fetchone())
def create_text_file(self, name: str, parent_id: str | None, content: str = "") -> dict[str, Any]:
self._ensure_initialized()
node_id = str(uuid.uuid4())
payload = _text_payload(content)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO document_nodes (
id, parent_id, type, name, mime_type, storage_kind, size,
content_text, preview_text, is_truncated_preview, blob_data, updated_at
)
VALUES (%s, %s, 'file', %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING *
""",
(
node_id,
parent_id,
name,
infer_mime_type(name),
payload["storage_kind"],
payload["size"],
payload["content_text"],
payload["preview_text"],
payload["is_truncated_preview"],
None,
),
)
return self._serialize(cur.fetchone())
def upload_file(self, name: str, parent_id: str | None, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
self._ensure_initialized()
node_id = str(uuid.uuid4())
payload = prepare_file_payload(name, raw_bytes, mime_type)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO document_nodes (
id, parent_id, type, name, mime_type, storage_kind, size,
content_text, preview_text, is_truncated_preview, blob_data, updated_at
)
VALUES (%s, %s, 'file', %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING *
""",
(
node_id,
parent_id,
name,
payload["mime_type"],
payload["storage_kind"],
payload["size"],
payload["content_text"],
payload["preview_text"],
payload["is_truncated_preview"],
payload["blob_data"],
),
)
return self._serialize(cur.fetchone())
def update_node(self, node_id: str, *, name: str | None | object = BaseDocumentStore._UNSET, parent_id: str | None | object = BaseDocumentStore._UNSET, content: str | None | object = BaseDocumentStore._UNSET) -> dict[str, Any]:
self._ensure_initialized()
row = self._fetch_node(node_id)
next_name = row["name"] if name is BaseDocumentStore._UNSET else name
next_parent_id = row["parent_id"] if parent_id is BaseDocumentStore._UNSET else parent_id
next_mime_type = row.get("mime_type") or ""
next_storage_kind = row.get("storage_kind") or "text"
next_size = row.get("size") or 0
next_content_text = row.get("content_text") or ""
next_preview_text = row.get("preview_text") or ""
next_is_truncated = bool(row.get("is_truncated_preview"))
next_blob_data = row.get("blob_data")
if row["type"] == "file" and name is not BaseDocumentStore._UNSET:
next_mime_type = infer_mime_type(next_name, next_mime_type)
if content is not BaseDocumentStore._UNSET:
payload = _text_payload(content)
next_storage_kind = payload["storage_kind"]
next_size = payload["size"]
next_content_text = payload["content_text"]
next_preview_text = payload["preview_text"]
next_is_truncated = payload["is_truncated_preview"]
next_blob_data = None
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE document_nodes
SET
name = %s,
parent_id = %s,
mime_type = %s,
storage_kind = %s,
size = %s,
content_text = %s,
preview_text = %s,
is_truncated_preview = %s,
blob_data = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
""",
(
next_name,
next_parent_id,
next_mime_type,
next_storage_kind,
next_size,
next_content_text,
next_preview_text,
next_is_truncated,
next_blob_data,
node_id,
),
)
updated = cur.fetchone()
if updated is None:
raise KeyError(node_id)
return self._serialize(updated)
def replace_blob(self, node_id: str, filename: str, raw_bytes: bytes, mime_type: str = "") -> dict[str, Any]:
self._ensure_initialized()
payload = prepare_file_payload(filename, raw_bytes, mime_type)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE document_nodes
SET
name = %s,
mime_type = %s,
storage_kind = %s,
size = %s,
content_text = %s,
preview_text = %s,
is_truncated_preview = %s,
blob_data = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND type = 'file'
RETURNING *
""",
(
filename,
payload["mime_type"],
payload["storage_kind"],
payload["size"],
payload["content_text"],
payload["preview_text"],
payload["is_truncated_preview"],
payload["blob_data"],
node_id,
),
)
updated = cur.fetchone()
if updated is None:
raise KeyError(node_id)
return self._serialize(updated)
def delete_node(self, node_id: str) -> None:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
WITH RECURSIVE descendants AS (
SELECT id FROM document_nodes WHERE id = %s
UNION ALL
SELECT child.id
FROM document_nodes child
INNER JOIN descendants parent ON child.parent_id = parent.id
)
DELETE FROM document_nodes
WHERE id IN (SELECT id FROM descendants)
""",
(node_id,),
)
def get_blob(self, node_id: str) -> BlobPayload:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, mime_type, content_text, blob_data
FROM document_nodes
WHERE id = %s AND type = 'file'
""",
(node_id,),
)
row = cur.fetchone()
if row is None:
raise FileNotFoundError(node_id)
blob_data = row.get("blob_data")
if blob_data is None:
content = (row.get("content_text") or "").encode("utf-8")
else:
content = bytes(blob_data)
return BlobPayload(
content=content,
mime_type=row.get("mime_type") or infer_mime_type(row.get("name") or ""),
filename=row["name"],
)
_document_store: BaseDocumentStore | None = None
def get_document_store() -> BaseDocumentStore:
global _document_store
if _document_store is not None:
return _document_store
backend = (os.getenv("DOCS_BACKEND") or "postgres").strip().lower()
if backend == "memory":
_document_store = InMemoryDocumentStore()
return _document_store
database_url = os.getenv("DATABASE_URL", "").strip()
if not database_url:
raise RuntimeError("缺少 DATABASE_URL,无法初始化 PostgreSQL 文档存储")
_document_store = PostgresDocumentStore(database_url)
return _document_store
def reset_document_store() -> None:
global _document_store
_document_store = None
+853
View File
@@ -0,0 +1,853 @@
import asyncio
import ipaddress
import json
import os
import re
from contextlib import suppress
from datetime import datetime
from typing import Any, Callable, Awaitable
from urllib.parse import urlparse
import httpx
import markitdown
from audit_store import get_audit_store
from llm import call_ollama, call_vlm_ocr, stream_ollama_events
from media_utils import extract_audio_wav_bytes, is_video_filename
from prompt import (
build_completion_prompts,
build_pro_completion_prompts,
prepare_prompt_context,
)
from risk_config import load_risk_config
from risk_control import RiskIdentity, estimate_tokens, get_risk_controller
try: # pragma: no cover - optional heavy dependency path
from tts_asr import generate_asr_response, generate_tts_response
except Exception: # pragma: no cover
generate_tts_response = None
generate_asr_response = None
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
SEARXNG_BASE_URL = (os.getenv("SEARXNG_BASE_URL", "http://searxng:8080") or "http://searxng:8080").rstrip("/")
SEARXNG_RESULT_LIMIT = max(1, int(os.getenv("SEARXNG_RESULT_LIMIT", "10") or "10"))
FIRECRAWL_BASE_URL = (os.getenv("FIRECRAWL_BASE_URL", "http://firecrawl:3002") or "http://firecrawl:3002").rstrip("/")
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "").strip()
WEB_SEARCH_QUERY_COUNT = max(3, min(5, int(os.getenv("WEB_SEARCH_QUERY_COUNT", "4") or "4")))
WEB_SEARCH_SELECTED_URL_LIMIT = max(5, min(20, int(os.getenv("WEB_SEARCH_SELECTED_URL_LIMIT", "10") or "10")))
WEB_SEARCH_CRAWL_CONCURRENCY = max(1, min(5, int(os.getenv("WEB_SEARCH_CRAWL_CONCURRENCY", "3") or "3")))
WEB_SEARCH_CRAWL_TIMEOUT_SECONDS = max(10, min(90, int(os.getenv("WEB_SEARCH_CRAWL_TIMEOUT_SECONDS", "35") or "35")))
_markitdown_instance = None
_risk_config = load_risk_config()
def _get_markitdown():
global _markitdown_instance
if _markitdown_instance is None:
_markitdown_instance = markitdown.MarkItDown()
return _markitdown_instance
def _convert_url_markdown(url: str) -> dict[str, Any]:
result = _get_markitdown().convert_url(url)
markdown = _normalize_multiline_text(str(getattr(result, "markdown", "") or ""))
title = str(getattr(result, "title", "") or "").strip()
return {
"url": url,
"title": title,
"markdown": markdown,
}
def _safe_unlink(path: str | None) -> None:
if not path:
return
with suppress(FileNotFoundError):
os.unlink(path)
def _sanitize_converted_markdown(text: str) -> str:
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
value = IMAGE_MARKDOWN_RE.sub("", value)
value = IMAGE_HTML_RE.sub("", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _normalize_multiline_text(value: str) -> str:
return (value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
def _is_blocked_public_url(url: str) -> bool:
try:
parsed = urlparse((url or "").strip())
except Exception:
return True
if parsed.scheme not in {"http", "https"}:
return True
host = (parsed.hostname or "").strip().lower()
if not host:
return True
if host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local"):
return True
try:
ip = ipaddress.ip_address(host)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast
except ValueError:
return False
def _strip_code_fence(value: str) -> str:
text = _normalize_multiline_text(value)
match = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", text, flags=re.IGNORECASE)
if match:
return match.group(1).strip()
return text
def _extract_json_array(value: str) -> list[Any]:
text = _strip_code_fence(value)
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, list) else []
except Exception:
match = re.search(r"\[[\s\S]*\]", text)
if not match:
return []
try:
parsed = json.loads(match.group(0))
except Exception:
return []
return parsed if isinstance(parsed, list) else []
def _normalize_search_queries(raw: str) -> list[str]:
items = _extract_json_array(raw)
queries: list[str] = []
if items:
for item in items:
text = str(item).strip()
if text and text not in queries:
queries.append(text)
else:
for line in _strip_code_fence(raw).splitlines():
text = re.sub(r"^\s*(?:[-*]|\d+[.)])\s*", "", line).strip()
if text and text not in queries:
queries.append(text)
return queries[:WEB_SEARCH_QUERY_COUNT]
def _clean_search_query_text(value: str) -> str:
text = _normalize_multiline_text(value)
text = re.sub(r"`{1,3}.*?`{1,3}", " ", text)
text = re.sub(r"[*_#>\[\]\(\){}|]+", " ", text)
text = re.sub(r"\s+", " ", text).strip(" -:;,./")
return text
def _build_fallback_search_queries(context: str, primary_queries: list[str]) -> list[str]:
queries: list[str] = []
def add(text: str) -> None:
cleaned = _clean_search_query_text(text)
if len(cleaned) < 2 or cleaned in queries:
return
queries.append(cleaned[:120])
for item in primary_queries:
add(item)
for line in context.splitlines():
cleaned = _clean_search_query_text(line)
if not cleaned:
continue
add(cleaned)
if re.search(r"[A-Za-z]", cleaned):
add(f"{cleaned} official")
add(f"{cleaned} github")
else:
add(f"{cleaned} 官网")
add(f"{cleaned} GitHub")
if context:
compact = _clean_search_query_text(context.replace("\n", " "))
if compact:
add(compact)
if re.search(r"[A-Za-z]", compact):
add(f"{compact} official documentation")
else:
add(f"{compact} 官方文档")
return queries[: max(WEB_SEARCH_QUERY_COUNT + 4, 8)]
def _build_no_result_content(queries: list[str], reason: str) -> str:
lines = [
"未检索到可用公开结果。",
"",
f"原因:{reason}",
]
if queries:
lines.extend(["", "已尝试的检索词:"])
lines.extend([f"- {query}" for query in queries])
lines.extend([
"",
"可以尝试缩短主题、补充专有名词,或直接给出官网、产品名、项目名、作者名等更具体的线索。",
])
return "\n".join(lines)
def _normalize_selected_urls(raw: str) -> list[str]:
urls: list[str] = []
for item in _extract_json_array(raw):
text = str(item).strip()
if not text or _is_blocked_public_url(text) or text in urls:
continue
urls.append(text)
return urls[:WEB_SEARCH_SELECTED_URL_LIMIT]
def _summarize_search_results(query: str, results: list[dict[str, Any]]) -> str:
lines = [f"Query: {query}"]
for index, item in enumerate(results, start=1):
lines.append(
f"{index}. title={item.get('title', '')} url={item.get('url', '')} "
f"score={item.get('score', '')} date={item.get('published_date', '')} snippet={item.get('snippet', '')}"
)
return "\n".join(lines)
async def _searxng_search(query: str, *, limit: int) -> list[dict[str, Any]]:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=10.0)) as client:
response = await client.get(
f"{SEARXNG_BASE_URL}/search",
params={"q": query, "format": "json"},
headers={
"Accept": "application/json",
"User-Agent": "llm-in-text-websearch/1.0",
"X-Forwarded-For": "127.0.0.1",
"X-Real-IP": "127.0.0.1",
},
)
response.raise_for_status()
payload = response.json()
results: list[dict[str, Any]] = []
for item in payload.get("results") or []:
url = str(item.get("url") or item.get("link") or "").strip()
if not url or _is_blocked_public_url(url):
continue
results.append({
"title": str(item.get("title") or "").strip(),
"url": url,
"score": item.get("score"),
"published_date": str(item.get("publishedDate") or item.get("published_date") or item.get("published") or "").strip(),
"snippet": _normalize_multiline_text(str(item.get("content") or item.get("snippet") or "")),
})
if len(results) >= limit:
break
return results
async def _firecrawl_scrape(url: str) -> dict[str, Any]:
headers = {"Content-Type": "application/json"}
if FIRECRAWL_API_KEY:
headers["Authorization"] = f"Bearer {FIRECRAWL_API_KEY}"
headers["X-Api-Key"] = FIRECRAWL_API_KEY
payload = None
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0, read=20.0)) as client:
try:
response = await client.post(
f"{FIRECRAWL_BASE_URL}/v1/scrape",
json={"url": url, "formats": ["markdown"]},
headers=headers,
)
response.raise_for_status()
payload = response.json()
except Exception:
payload = None
if payload is None:
return await asyncio.to_thread(_convert_url_markdown, url)
data = payload.get("data") or {}
markdown = _normalize_multiline_text(str(data.get("markdown") or data.get("content") or ""))
metadata = data.get("metadata") or {}
return {
"url": url,
"title": str(metadata.get("title") or "").strip(),
"markdown": markdown,
}
def sanitize_inline_completion_content(text: str, prefill: str = "") -> str:
value = (text or "").strip()
if not value:
return ""
fim_middle = value.rfind("<|fim_middle|>")
if fim_middle >= 0:
value = value[fim_middle + len("<|fim_middle|>") :]
end_index = value.find("<|end|>")
if end_index >= 0:
value = value[:end_index]
quoted = re.findall(r'"([^"]+)"', value)
if quoted:
value = quoted[-1]
marker_index = max(value.rfind("|fim_middle|>"), value.rfind("<|start|>assistant"))
if marker_index >= 0:
tail = value.split(">")[-1]
if tail:
value = tail
value = value.strip()
if prefill and value.startswith(prefill):
value = value[len(prefill) :]
return value.strip()
def _payload_identity(payload: dict[str, Any]) -> RiskIdentity:
risk = payload.get("risk") or {}
return RiskIdentity(
request_id=risk.get("request_id") or payload["request_id"],
session_hash=risk.get("session_hash", ""),
ip_hash=risk.get("ip_hash", ""),
route=payload.get("route", payload.get("job_type", payload.get("request_id", ""))),
method="POST",
)
async def _enter_llm_execution(payload: dict[str, Any], emit: Callable[[str, dict[str, Any]], Awaitable[None]]) -> tuple[RiskIdentity, dict[str, Any], list[str]]:
risk = payload.get("risk") or {}
identity = _payload_identity(payload)
delay_ms = int(risk.get("delay_ms", 0) or 0)
policy = risk.get("policy") or {}
if delay_ms > 0:
await emit("resource", {"phase": "delay", "delay_ms": delay_ms})
await asyncio.sleep(delay_ms / 1000.0)
controller = get_risk_controller(_risk_config)
lock_keys = await controller.acquire_execution_slot(identity, model=policy.get("model", ""))
return identity, risk, lock_keys
async def _exit_llm_execution(
payload: dict[str, Any],
identity: RiskIdentity,
risk: dict[str, Any],
lock_keys: list[str],
*,
status: str,
actual_output_text: str = "",
error_code: str = "",
) -> None:
policy = (risk.get("policy") or {})
controller = get_risk_controller(_risk_config)
await controller.release_execution_slot(identity, lock_keys, model=policy.get("model", ""))
await controller.record_model_result(model=policy.get("model", ""), success=(status == "completed"))
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
estimated_input_tokens = int(risk.get("estimated_input_tokens", 0) or 0)
profile = policy.get("profile", "completion")
pricing_out = {
"completion": _risk_config.completion_output_cost_per_1k,
"pro": _risk_config.pro_output_cost_per_1k,
"vision": _risk_config.vision_output_cost_per_1k,
}.get(profile, _risk_config.completion_output_cost_per_1k)
actual_output_tokens = estimate_tokens(actual_output_text)
actual_cost = round((estimated_input_tokens / 1000.0) * {
"completion": _risk_config.completion_input_cost_per_1k,
"pro": _risk_config.pro_input_cost_per_1k,
"vision": _risk_config.vision_input_cost_per_1k,
}.get(profile, _risk_config.completion_input_cost_per_1k) + (actual_output_tokens / 1000.0) * pricing_out, 8)
await asyncio.to_thread(
store.record_llm_call,
{
"request_id": payload["request_id"],
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"job_type": policy.get("job_type", ""),
"model": policy.get("model", ""),
"estimated_input_tokens": estimated_input_tokens,
"max_output_tokens": int(policy.get("max_output_tokens", 0) or 0),
"estimated_cost": float(risk.get("estimated_cost", 0.0) or 0.0),
"actual_output_chars": len(actual_output_text or ""),
"actual_cost": actual_cost,
"status": status,
"error_code": error_code,
"metadata": {"profile": profile},
},
)
async def completion_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
req = payload["request"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt, user_prompt, prefill = build_completion_prompts(
req["prefix"],
req["suffix"],
req.get("languageId", "markdown"),
location=payload.get("location", ""),
thinking_level=req.get("model_thinking", "low"),
preferences=req.get("user_preferences"),
)
policy = risk.get("policy") or {}
try:
result = await call_ollama(
user_prompt,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-completion',
temperature=float(policy.get("temperature", req.get("temperature", 0.7))),
thinking=policy.get("thinking"),
model=policy.get("model"),
prefill=prefill or None,
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
)
content = sanitize_inline_completion_content(result.get("content") or "", prefill=prefill or "")
if is_cancelled():
raise asyncio.CancelledError()
await emit("result", {"content": content})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def pro_completion_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
req = payload["request"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt, user_prompt = build_pro_completion_prompts(
prefix=req["prefix"],
suffix=req["suffix"],
instruction=req.get("instruction", ""),
language_id=req.get("languageId", "markdown"),
location=payload.get("location", ""),
pro_thinking_level=req.get("pro_thinking", "medium"),
preferences=req.get("user_preferences"),
)
chunks: list[str] = []
policy = risk.get("policy") or {}
try:
async for event_type, delta in stream_ollama_events(
user_prompt,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-pro',
temperature=float(policy.get("temperature", 0.7)),
thinking=policy.get("thinking"),
model=policy.get("model"),
enable_thinking=True,
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
):
if is_cancelled():
raise asyncio.CancelledError()
if event_type == "thinking":
await emit("progress", {"phase": "thinking"})
continue
if delta:
chunks.append(delta)
await emit("result", {"delta": delta})
content = "".join(chunks)
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def web_search_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
req = payload["request"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
policy = risk.get("policy") or {}
prefix = _normalize_multiline_text(req.get("prefix", ""))
suffix = _normalize_multiline_text(req.get("suffix", ""))
context = "\n\n".join(part for part in [prefix, suffix] if part).strip()
try:
await emit("progress", {"phase": "keywords", "message": "正在生成搜索关键词"})
keyword_prompt = (
"你是联网研究助手。请根据下面的上下文,生成 3 到 5 个适合在搜索引擎中直接使用的检索关键词或短句。\n"
"要求:\n"
"- 只返回 JSON 数组字符串\n"
"- 每个元素是一个简洁检索词\n"
"- 不要解释,不要 Markdown\n\n"
f"上下文:\n{context}"
)
keyword_result = await call_ollama(
keyword_prompt,
system_prompt="Return only a JSON array of search queries.",
tag=f'{payload["request_id"][:8]}-webq',
temperature=float(policy.get("temperature", 0.4)),
thinking=policy.get("thinking"),
model=policy.get("model"),
max_output_tokens=min(int(policy.get("max_output_tokens", 0) or 1024), 1024),
)
queries = _normalize_search_queries(keyword_result.get("content") or "")
if not queries:
queries = [_normalize_multiline_text(prefix or suffix)[:120] or "general research query"]
if is_cancelled():
raise asyncio.CancelledError()
await emit("progress", {"phase": "searching", "message": "正在通过 SearXNG 搜索"})
search_sections: list[str] = []
search_candidates: list[dict[str, Any]] = []
attempted_queries: list[str] = []
for query in queries:
if is_cancelled():
raise asyncio.CancelledError()
attempted_queries.append(query)
results = await _searxng_search(query, limit=SEARXNG_RESULT_LIMIT)
if not results:
continue
search_sections.append(_summarize_search_results(query, results))
search_candidates.extend(results)
if not search_candidates:
fallback_queries = _build_fallback_search_queries(context, queries)
retry_queries = [query for query in fallback_queries if query not in attempted_queries]
if retry_queries:
await emit("progress", {"phase": "searching", "message": "搜索结果较少,正在尝试更宽泛的检索词"})
for query in retry_queries:
if is_cancelled():
raise asyncio.CancelledError()
attempted_queries.append(query)
results = await _searxng_search(query, limit=SEARXNG_RESULT_LIMIT)
if not results:
continue
search_sections.append(_summarize_search_results(query, results))
search_candidates.extend(results)
if not search_candidates:
content = _build_no_result_content(attempted_queries, "SearXNG 未返回可用结果")
created_at = str(req.get("created_at") or payload.get("created_at") or "").strip() or datetime.now().isoformat()
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"], "created_at": created_at}
deduped_candidates: list[dict[str, Any]] = []
seen_urls: set[str] = set()
for item in search_candidates:
url = item["url"]
if url in seen_urls:
continue
seen_urls.add(url)
deduped_candidates.append(item)
await emit("progress", {"phase": "selecting_urls", "message": "正在筛选可信网址"})
search_results_text = "\n\n".join(search_sections)
selection_prompt = (
"你是研究检索筛选器。下面是多组搜索结果,请从中挑选 5 到 20 个最可信、最相关、最值得进一步抓取的 URL。\n"
"优先选择:官方文档、权威机构、原始来源、信息完整且日期清晰的页面。\n"
"只返回 JSON 数组,元素必须是 URL 字符串。\n\n"
f"原始上下文:\n{context}\n\n"
f"搜索结果:\n{search_results_text}"
)
selection_result = await call_ollama(
selection_prompt,
system_prompt="Return only a JSON array of selected URLs.",
tag=f'{payload["request_id"][:8]}-webu',
temperature=0.2,
thinking=policy.get("thinking"),
model=policy.get("model"),
max_output_tokens=min(int(policy.get("max_output_tokens", 0) or 1024), 1024),
)
selected_urls = _normalize_selected_urls(selection_result.get("content") or "")
if not selected_urls:
selected_urls = [item["url"] for item in deduped_candidates[:WEB_SEARCH_SELECTED_URL_LIMIT]]
if is_cancelled():
raise asyncio.CancelledError()
await emit("progress", {"phase": "crawling", "message": "正在抓取网页内容"})
selected_url_set = set(selected_urls)
selected_candidates = [item for item in deduped_candidates if item["url"] in selected_url_set][:WEB_SEARCH_SELECTED_URL_LIMIT]
crawl_sem = asyncio.Semaphore(WEB_SEARCH_CRAWL_CONCURRENCY)
async def _crawl_candidate(item: dict[str, Any]) -> dict[str, Any] | None:
if is_cancelled():
raise asyncio.CancelledError()
async with crawl_sem:
try:
scraped = await asyncio.wait_for(
_firecrawl_scrape(item["url"]),
timeout=WEB_SEARCH_CRAWL_TIMEOUT_SECONDS,
)
except Exception:
return None
if not scraped.get("markdown"):
return None
return {
"url": item["url"],
"title": item.get("title") or scraped.get("title") or "",
"score": item.get("score"),
"published_date": item.get("published_date") or "",
"content": scraped["markdown"],
}
crawl_results = await asyncio.gather(*[_crawl_candidate(item) for item in selected_candidates])
crawled_pages = [page for page in crawl_results if page]
if not crawled_pages:
content = _build_no_result_content(selected_urls, "已检索到候选网页,但未抓取到可用正文")
created_at = str(req.get("created_at") or payload.get("created_at") or "").strip() or datetime.now().isoformat()
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"], "created_at": created_at}
await emit("progress", {"phase": "synthesizing", "message": "正在整理搜索结果"})
page_sections = []
for index, page in enumerate(crawled_pages, start=1):
page_sections.append(
f"[Source {index}]\n"
f"URL: {page['url']}\n"
f"Title: {page.get('title', '')}\n"
f"Score: {page.get('score', '')}\n"
f"Date: {page.get('published_date', '')}\n"
f"Content:\n{page['content'][:12000]}"
)
crawled_text = "\n\n".join(page_sections)
synthesis_prompt = (
"你是研究写作助手。请根据原始上下文和抓取到的网页内容,写出一篇长篇、结构完整、信息密集的 Markdown 正文。\n"
"要求:\n"
"- 不要写标题\n"
"- 不要写引用编号、来源表或 URL 列表\n"
"- 直接输出最终正文\n"
"- 如果信息存在不确定性,用审慎措辞表达\n\n"
f"原始上下文:\n{context}\n\n"
f"抓取内容:\n{crawled_text}"
)
# 流式合成 — 逐 delta emit,前端可实时渲染
from llm import stream_ollama_events
accumulated: list[str] = []
async for event_type, text in stream_ollama_events(
synthesis_prompt,
system_prompt="Return only the final markdown body with no title and no citations list.",
tag=f'{payload["request_id"][:8]}-webf',
temperature=float(policy.get("temperature", 0.4)),
thinking=policy.get("thinking"),
model=policy.get("model"),
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
):
if is_cancelled():
raise asyncio.CancelledError()
# 只推送 content delta,不展示 thinking
if event_type == "content":
accumulated.append(text)
await emit("delta", {"text": text})
content = _normalize_multiline_text("".join(accumulated))
if not content:
raise RuntimeError("联网搜索生成了空结果")
created_at = str(req.get("created_at") or payload.get("created_at") or "").strip() or datetime.now().isoformat()
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"], "created_at": created_at}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="web_search_failed")
raise
async def compress_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
content = payload["content"]
doc_type = payload.get("docType", "txt")
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt = (
f"你是一个专业的文档摘要助手。请将以下 {doc_type} 类型文档内容进行精简压缩,"
"保留核心信息和关键要点,去除冗余和啰嗦的表述。"
"请直接输出压缩后的内容,不要添加任何解释性文字。"
)
policy = risk.get("policy") or {}
try:
result = await call_ollama(
content,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-compress',
model=policy.get("model"),
temperature=float(policy.get("temperature", 0.2)),
thinking=policy.get("thinking"),
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
)
if is_cancelled():
raise asyncio.CancelledError()
compressed = result.get("content") or ""
await emit("result", {"content": compressed})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=compressed)
return {"content": compressed, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def ocr_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
path = payload["input_path"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
try:
filename = payload.get("filename", "image.jpg")
language = payload.get("language", "auto")
media_type = payload.get("media_type", "image")
mime_type = payload.get("mime_type", "") or ""
with open(path, "rb") as handle:
media_bytes = handle.read()
await emit("progress", {"phase": "ocr", "media_type": media_type})
ocr_text = await call_vlm_ocr(
media_bytes,
language,
mime_type=mime_type or "application/octet-stream",
media_type=media_type,
)
if is_cancelled():
raise asyncio.CancelledError()
result = {
"text": ocr_text,
"ocr_text": ocr_text,
"filename": filename,
"media_type": media_type,
}
if media_type == "video" or is_video_filename(filename, mime_type):
asr_text = ""
if generate_asr_response is not None:
try:
await emit("progress", {"phase": "asr", "media_type": media_type})
audio_bytes = await asyncio.to_thread(extract_audio_wav_bytes, path)
asr_response = await generate_asr_response(audio_bytes, language)
asr_text = getattr(asr_response, "text", "") or ""
except Exception as exc:
asr_text = f"(音频解析失败: {exc})"
if ocr_text.strip() or asr_text.strip():
text_parts = []
if ocr_text.strip():
text_parts.append(f"## 视频画面 OCR\n\n{ocr_text.strip()}")
if asr_text.strip():
text_parts.append(f"## 视频音频 ASR\n\n{asr_text.strip()}")
result["text"] = "\n\n".join(text_parts)
result["asr_text"] = asr_text
await emit("result", result)
await _exit_llm_execution(
payload,
identity,
risk,
lock_keys,
status="completed",
actual_output_text=result["text"],
)
return result
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="ocr_failed")
raise
finally:
_safe_unlink(path)
async def convert_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
path = payload["input_path"]
filename = payload.get("filename", "document")
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
_safe_unlink(path)
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
try:
if ext == ".txt":
with open(path, "rb") as handle:
markdown = _sanitize_converted_markdown(handle.read().decode("utf-8", errors="ignore"))
else:
md = _get_markitdown()
result = await asyncio.to_thread(md.convert, path)
markdown = _sanitize_converted_markdown(result.text_content)
if is_cancelled():
raise asyncio.CancelledError()
await emit("result", {"markdown": markdown})
return {"markdown": markdown, "filename": filename}
finally:
_safe_unlink(path)
async def tts_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
if generate_tts_response is None:
raise RuntimeError("TTS 功能当前不可用")
response = await generate_tts_response(
text=payload["text"],
instruct=payload.get("instruct", ""),
speaker=payload.get("speaker", "Vivian"),
output_format=payload.get("format", "wav"),
)
if is_cancelled():
raise asyncio.CancelledError()
result = response.dict()
await emit("result", result)
return result
async def asr_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
if generate_asr_response is None:
raise RuntimeError("ASR 功能当前不可用")
path = payload["input_path"]
try:
with open(path, "rb") as handle:
audio_bytes = handle.read()
response = await generate_asr_response(audio_bytes, payload.get("language", "zh-CN"))
if is_cancelled():
raise asyncio.CancelledError()
result = response.dict()
await emit("result", result)
return result
finally:
_safe_unlink(path)
+733
View File
@@ -0,0 +1,733 @@
import asyncio
import inspect
import json
import logging
import os
import tempfile
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
logger = logging.getLogger("job_system")
try: # pragma: no cover - optional dependency in tests
from redis import asyncio as redis_asyncio # type: ignore
except Exception: # pragma: no cover - optional dependency in tests
redis_asyncio = None
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
TERMINAL_EVENTS = {"done", "error", "cancelled"}
JOB_TYPES = (
"completion",
"pro_completion",
"web_search",
"compress",
"ocr",
"convert",
"tts",
"asr",
)
DEFAULT_CONCURRENCY = {
"completion": 2,
"pro_completion": 1,
"web_search": 1,
"compress": 1,
"ocr": 1,
"convert": 1,
"tts": 1,
"asr": 1,
}
DEFAULT_QUEUE_SIZE = {
"completion": 16,
"pro_completion": 8,
"web_search": 4,
"compress": 8,
"ocr": 8,
"convert": 8,
"tts": 4,
"asr": 4,
}
class JobSystemError(RuntimeError):
"""任务系统内部错误"""
def __init__(self, message: str = "任务系统错误", error_code: str = "job_system_error") -> None:
super().__init__(message)
self.message = message
self.error_code = error_code
def __str__(self) -> str:
return f"{self.error_code}: {self.message}"
class QueueFullError(JobSystemError):
"""任务队列已满"""
def __init__(self, job_type: str, max_queue: int) -> None:
super().__init__(f"{job_type} 队列已满 (当前: {max_queue}/{max_queue})", "queue_full")
self.job_type = job_type
self.max_queue = max_queue
@dataclass(frozen=True)
class QueueConfig:
job_type: str
concurrency: int
max_queue: int
Handler = Callable[[dict[str, Any], Callable[[str, dict[str, Any]], Awaitable[None]], Callable[[], bool]], Awaitable[dict[str, Any]]]
def _bool_env(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _int_env(name: str, default: int) -> int:
try:
return max(1, int(os.getenv(name, str(default))))
except (TypeError, ValueError):
return default
def _float_env(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def _now_ms() -> int:
return int(time.time() * 1000)
def _busy_level(ratio: float) -> str:
if ratio >= _float_env("JOB_BUSY_FULL_THRESHOLD", 1.0):
return "full"
if ratio >= _float_env("JOB_BUSY_HIGH_THRESHOLD", 0.75):
return "busy"
if ratio >= _float_env("JOB_BUSY_NORMAL_THRESHOLD", 0.25):
return "normal"
return "idle"
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False)
def _json_loads(value: str | bytes | None, default: Any = None) -> Any:
if value is None:
return default
if isinstance(value, bytes):
value = value.decode("utf-8")
if not value:
return default
return json.loads(value)
def _queue_config(job_type: str) -> QueueConfig:
upper = job_type.upper()
concurrency = _int_env(f"JOB_{upper}_CONCURRENCY", DEFAULT_CONCURRENCY[job_type])
max_queue = _int_env(f"JOB_{upper}_MAX_QUEUE", DEFAULT_QUEUE_SIZE[job_type])
return QueueConfig(job_type=job_type, concurrency=concurrency, max_queue=max_queue)
def get_job_backend_name() -> str:
value = (os.getenv("JOB_BACKEND") or "").strip().lower()
if value:
return value
if redis_asyncio is not None:
return "redis"
return "memory"
def _shared_temp_dir() -> Path:
path = Path(os.getenv("JOB_SHARED_TEMP_DIR", tempfile.gettempdir()) or tempfile.gettempdir())
path.mkdir(parents=True, exist_ok=True)
return path
def persist_temp_input(raw_bytes: bytes, suffix: str) -> str:
directory = _shared_temp_dir()
fd, path = tempfile.mkstemp(prefix="job-input-", suffix=suffix, dir=directory)
os.close(fd)
with open(path, "wb") as handle:
handle.write(raw_bytes)
return path
async def _maybe_await(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
class BaseJobManager:
def __init__(self) -> None:
self.handlers: dict[str, Handler] = {}
def register_handler(self, job_type: str, handler: Handler) -> None:
self.handlers[job_type] = handler
async def submit(self, job_type: str, payload: dict[str, Any], request_id: str | None = None) -> str:
raise NotImplementedError
async def cancel(self, job_id: str, reason: str = "abort") -> dict[str, Any]:
raise NotImplementedError
async def get_status(self, job_id: str) -> dict[str, Any] | None:
raise NotImplementedError
async def stream_events(self, job_id: str) -> AsyncIterator[dict[str, Any]]:
raise NotImplementedError
async def close(self) -> None:
return None
class InMemoryJobManager(BaseJobManager):
def __init__(self) -> None:
super().__init__()
self.jobs: dict[str, dict[str, Any]] = {}
self.event_history: dict[str, list[dict[str, Any]]] = {}
self.subscribers: dict[str, list[asyncio.Queue]] = {}
self.queues = {job_type: asyncio.Queue() for job_type in JOB_TYPES}
self.semaphores = {job_type: asyncio.Semaphore(_queue_config(job_type).concurrency) for job_type in JOB_TYPES}
self.queue_counts = {job_type: 0 for job_type in JOB_TYPES}
self.running_counts = {job_type: 0 for job_type in JOB_TYPES}
self.running_tasks: dict[str, asyncio.Task] = {}
self.worker_tasks: list[asyncio.Task] = []
self.started = False
self.lock = asyncio.Lock()
async def _ensure_started(self) -> None:
if self.started:
return
self.started = True
for job_type in JOB_TYPES:
self.worker_tasks.append(asyncio.create_task(self._worker_loop(job_type)))
def _metrics(self, job_type: str) -> dict[str, Any]:
config = _queue_config(job_type)
queued = self.queue_counts[job_type]
running = self.running_counts[job_type]
capacity = max(config.max_queue + config.concurrency, 1)
ratio = min((queued + running) / capacity, 1.0)
return {
"queue_position": queued if queued > 0 else 0,
"queued_count": queued,
"running_count": running,
"concurrency_limit": config.concurrency,
"max_queue": config.max_queue,
"busy_ratio": round(ratio, 4),
"busy_level": _busy_level(ratio),
}
async def _publish(self, job_id: str, event: str, data: dict[str, Any]) -> None:
event_payload = {"event": event, **data}
self.event_history.setdefault(job_id, []).append(event_payload)
for queue in self.subscribers.get(job_id, []):
await queue.put(event_payload)
async def submit(self, job_type: str, payload: dict[str, Any], request_id: str | None = None) -> str:
await self._ensure_started()
if job_type not in self.handlers:
raise JobSystemError(f"missing handler for job type: {job_type}")
async with self.lock:
config = _queue_config(job_type)
if self.queue_counts[job_type] >= config.max_queue:
raise QueueFullError(f"{job_type} queue is full")
job_id = request_id or str(uuid.uuid4())
self.jobs[job_id] = {
"job_id": job_id,
"request_id": job_id,
"job_type": job_type,
"status": "queued",
"payload": payload,
"result": None,
"error": "",
"cancel_requested": False,
"created_at": _now_ms(),
"updated_at": _now_ms(),
}
self.event_history[job_id] = []
self.queue_counts[job_type] += 1
metrics = self._metrics(job_type)
await self._publish(job_id, "queued", {"job_id": job_id, "type": job_type, "status": "queued", **metrics})
await self.queues[job_type].put(job_id)
return job_id
async def cancel(self, job_id: str, reason: str = "abort") -> dict[str, Any]:
async with self.lock:
job = self.jobs.get(job_id)
if not job:
return {"cancelled": False, "status": "not_found"}
if job["status"] in TERMINAL_STATUSES:
return {"cancelled": False, "status": job["status"]}
job["cancel_requested"] = True
job["updated_at"] = _now_ms()
task = self.running_tasks.get(job_id)
if task and not task.done():
task.cancel()
if job["status"] == "queued":
job["status"] = "cancelled"
self.queue_counts[job["job_type"]] = max(0, self.queue_counts[job["job_type"]] - 1)
metrics = self._metrics(job["job_type"])
else:
job["status"] = "cancelled"
metrics = self._metrics(job["job_type"])
await self._publish(job_id, "cancelled", {"job_id": job_id, "type": job["job_type"], "status": "cancelled", "reason": reason, **metrics})
return {"cancelled": True, "status": "ok"}
async def get_status(self, job_id: str) -> dict[str, Any] | None:
job = self.jobs.get(job_id)
if not job:
return None
metrics = self._metrics(job["job_type"])
return {
"job_id": job_id,
"request_id": job["request_id"],
"type": job["job_type"],
"status": job["status"],
"result": job["result"],
"error": job["error"],
**metrics,
}
async def stream_events(self, job_id: str) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue = asyncio.Queue()
self.subscribers.setdefault(job_id, []).append(queue)
last_index = 0
try:
while True:
history = list(self.event_history.get(job_id, []))
while last_index < len(history):
item = history[last_index]
last_index += 1
yield item
if item.get("event") in TERMINAL_EVENTS:
return
event = await queue.get()
last_index = len(self.event_history.get(job_id, []))
yield event
if event["event"] in TERMINAL_EVENTS:
break
finally:
with suppress(ValueError):
self.subscribers.get(job_id, []).remove(queue)
async def _worker_loop(self, job_type: str) -> None:
queue = self.queues[job_type]
sem = self.semaphores[job_type]
while True:
job_id = await queue.get()
async with self.lock:
job = self.jobs.get(job_id)
if not job or job["status"] == "cancelled":
continue
await sem.acquire()
task = asyncio.create_task(self._run_job(job_id))
self.running_tasks[job_id] = task
async def _run_job(self, job_id: str) -> None:
job = self.jobs[job_id]
job_type = job["job_type"]
try:
async with self.lock:
if job["status"] == "cancelled":
return
self.queue_counts[job_type] = max(0, self.queue_counts[job_type] - 1)
self.running_counts[job_type] += 1
job["status"] = "running"
job["updated_at"] = _now_ms()
metrics = self._metrics(job_type)
await self._publish(job_id, "started", {"job_id": job_id, "type": job_type, "status": "running", **metrics})
async def emit(event: str, data: dict[str, Any]) -> None:
metrics_now = self._metrics(job_type)
await self._publish(job_id, event, {"job_id": job_id, "type": job_type, "status": job["status"], **metrics_now, **data})
def is_cancelled() -> bool:
return bool(job.get("cancel_requested"))
result = await self.handlers[job_type](job["payload"], emit, is_cancelled)
async with self.lock:
if job["cancel_requested"]:
job["status"] = "cancelled"
metrics = self._metrics(job_type)
await emit("cancelled", {"reason": "abort"})
return
job["status"] = "completed"
job["result"] = result
job["updated_at"] = _now_ms()
metrics = self._metrics(job_type)
await self._publish(job_id, "done", {"job_id": job_id, "type": job_type, "status": "completed", "result": result, **metrics})
except asyncio.CancelledError:
async with self.lock:
job["status"] = "cancelled"
job["cancel_requested"] = True
metrics = self._metrics(job_type)
await self._publish(job_id, "cancelled", {"job_id": job_id, "type": job_type, "status": "cancelled", **metrics})
raise
except Exception as exc:
logger.exception("in-memory job failed job_id=%s type=%s", job_id, job_type)
async with self.lock:
job["status"] = "failed"
job["error"] = str(exc)
job["updated_at"] = _now_ms()
metrics = self._metrics(job_type)
await self._publish(job_id, "error", {"job_id": job_id, "type": job_type, "status": "failed", "error": str(exc), **metrics})
finally:
async with self.lock:
self.running_counts[job_type] = max(0, self.running_counts[job_type] - 1)
self.running_tasks.pop(job_id, None)
self.semaphores[job_type].release()
async def close(self) -> None:
for task in self.worker_tasks:
task.cancel()
for task in self.running_tasks.values():
task.cancel()
for task in self.worker_tasks:
with suppress(asyncio.CancelledError):
await task
self.worker_tasks.clear()
self.running_tasks.clear()
self.started = False
class RedisJobManager(BaseJobManager):
def __init__(self) -> None:
super().__init__()
if redis_asyncio is None:
raise JobSystemError("redis package is not installed")
self.redis = redis_asyncio.from_url(
os.getenv("REDIS_URL", "redis://localhost:6379/0"),
encoding="utf-8",
decode_responses=True,
)
self.prefix = (os.getenv("JOB_REDIS_PREFIX") or "llmtext:jobs").strip() or "llmtext:jobs"
self.state_ttl = _int_env("JOB_STATE_TTL_SECONDS", 600)
self.event_ttl = _int_env("JOB_EVENT_TTL_SECONDS", 600)
def _queue_key(self, job_type: str) -> str:
return f"{self.prefix}:queue:{job_type}"
def _event_key(self, job_id: str) -> str:
return f"{self.prefix}:events:{job_id}"
def _state_key(self, job_id: str) -> str:
return f"{self.prefix}:state:{job_id}"
def _metrics_key(self, job_type: str) -> str:
return f"{self.prefix}:metrics:{job_type}"
def _group_name(self, job_type: str) -> str:
return f"{self.prefix}:group:{job_type}"
async def ensure_groups(self) -> None:
for job_type in JOB_TYPES:
stream = self._queue_key(job_type)
group = self._group_name(job_type)
try:
await self.redis.xgroup_create(stream, group, id="0-0", mkstream=True)
except Exception as exc: # pragma: no cover - redis-specific
if "BUSYGROUP" not in str(exc):
raise
async def close(self) -> None:
await self.redis.aclose()
async def _metrics(self, job_type: str) -> dict[str, Any]:
raw = await self.redis.hgetall(self._metrics_key(job_type))
queued = int(raw.get("queued_count", "0") or 0)
running = int(raw.get("running_count", "0") or 0)
config = _queue_config(job_type)
capacity = max(config.max_queue + config.concurrency, 1)
ratio = min((queued + running) / capacity, 1.0)
return {
"queued_count": queued,
"running_count": running,
"concurrency_limit": config.concurrency,
"max_queue": config.max_queue,
"busy_ratio": round(ratio, 4),
"busy_level": _busy_level(ratio),
}
async def _emit_event(self, job_id: str, event: str, data: dict[str, Any]) -> None:
key = self._event_key(job_id)
payload = {k: _json_dumps(v) if not isinstance(v, str) else v for k, v in data.items()}
payload["event"] = event
await self.redis.xadd(key, payload, maxlen=_int_env("JOB_EVENT_STREAM_MAXLEN", 512), approximate=True)
await self.redis.expire(key, self.event_ttl)
async def _set_state(self, job_id: str, state: dict[str, Any]) -> None:
serializable = {k: _json_dumps(v) if isinstance(v, (dict, list)) else str(v) for k, v in state.items()}
await self.redis.hset(self._state_key(job_id), mapping=serializable)
await self.redis.expire(self._state_key(job_id), self.state_ttl)
async def submit(self, job_type: str, payload: dict[str, Any], request_id: str | None = None) -> str:
config = _queue_config(job_type)
metrics = await self._metrics(job_type)
if metrics["queued_count"] >= config.max_queue:
raise QueueFullError(f"{job_type} queue is full")
job_id = request_id or str(uuid.uuid4())
created_at = _now_ms()
state = {
"job_id": job_id,
"request_id": job_id,
"type": job_type,
"status": "queued",
"error": "",
"created_at": created_at,
"updated_at": created_at,
"cancel_requested": "0",
}
await self._set_state(job_id, state)
await self.redis.hincrby(self._metrics_key(job_type), "queued_count", 1)
await self.redis.expire(self._metrics_key(job_type), self.state_ttl)
metrics = await self._metrics(job_type)
await self._emit_event(job_id, "queued", {"job_id": job_id, "type": job_type, "status": "queued", **metrics})
await self.redis.xadd(self._queue_key(job_type), {"job_id": job_id, "payload": _json_dumps(payload), "request_id": job_id})
return job_id
async def cancel(self, job_id: str, reason: str = "abort") -> dict[str, Any]:
state = await self.get_status(job_id)
if not state:
return {"cancelled": False, "status": "not_found"}
if state["status"] in TERMINAL_STATUSES:
return {"cancelled": False, "status": state["status"]}
await self.redis.hset(self._state_key(job_id), mapping={"cancel_requested": "1", "status": "cancelled", "updated_at": _now_ms(), "cancel_reason": reason})
metrics = await self._metrics(state["type"])
await self._emit_event(job_id, "cancelled", {"job_id": job_id, "type": state["type"], "status": "cancelled", "reason": reason, **metrics})
return {"cancelled": True, "status": "ok"}
async def get_status(self, job_id: str) -> dict[str, Any] | None:
state = await self.redis.hgetall(self._state_key(job_id))
if not state:
return None
job_type = state.get("type", "")
metrics = await self._metrics(job_type) if job_type else {}
result = state.get("result")
error = state.get("error", "")
return {
"job_id": state.get("job_id", job_id),
"request_id": state.get("request_id", job_id),
"type": job_type,
"status": state.get("status", "queued"),
"error": error,
"result": _json_loads(result, result),
"cancel_requested": state.get("cancel_requested") == "1",
**metrics,
}
async def stream_events(self, job_id: str) -> AsyncIterator[dict[str, Any]]:
stream = self._event_key(job_id)
last_id = "0-0"
while True:
events = await self.redis.xread({stream: last_id}, block=1000, count=20)
if not events:
state = await self.get_status(job_id)
if state and state["status"] in TERMINAL_STATUSES:
break
continue
for _, entries in events:
for entry_id, fields in entries:
last_id = entry_id
event_payload: dict[str, Any] = {}
for key, value in fields.items():
if key == "event":
event_payload[key] = value
continue
try:
event_payload[key] = json.loads(value)
except Exception:
event_payload[key] = value
yield event_payload
if event_payload.get("event") in TERMINAL_EVENTS:
return
class RedisWorker:
def __init__(self, manager: RedisJobManager) -> None:
self.manager = manager
self.running_tasks: dict[str, asyncio.Task] = {}
self.queue_semaphores = {job_type: asyncio.Semaphore(_queue_config(job_type).concurrency) for job_type in JOB_TYPES}
self.poll_interval = _float_env("JOB_CANCEL_POLL_SECONDS", 0.5)
self.consumer_name = (os.getenv("JOB_CONSUMER_NAME") or f"worker-{uuid.uuid4().hex[:8]}").strip()
async def run_forever(self) -> None:
await self.manager.ensure_groups()
cancel_task = asyncio.create_task(self._cancel_watch_loop())
consumers = [asyncio.create_task(self._consume_loop(job_type)) for job_type in JOB_TYPES]
try:
await asyncio.gather(*consumers)
finally:
cancel_task.cancel()
with suppress(asyncio.CancelledError):
await cancel_task
async def _cancel_watch_loop(self) -> None:
while True:
await asyncio.sleep(self.poll_interval)
for job_id, task in list(self.running_tasks.items()):
state = await self.manager.get_status(job_id)
if state and state.get("cancel_requested") and not task.done():
task.cancel()
async def _consume_loop(self, job_type: str) -> None:
queue_key = self.manager._queue_key(job_type)
group = self.manager._group_name(job_type)
semaphore = self.queue_semaphores[job_type]
while True:
try:
streams = await self.manager.redis.xreadgroup(group, self.consumer_name, {queue_key: ">"}, count=1, block=1000)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("redis worker consume loop retrying type=%s error=%s", job_type, exc)
await asyncio.sleep(1)
continue
if not streams:
continue
for _, messages in streams:
for message_id, fields in messages:
await semaphore.acquire()
task = asyncio.create_task(self._run_message(job_type, queue_key, group, message_id, fields, semaphore))
self.running_tasks[fields["job_id"]] = task
async def _run_message(
self,
job_type: str,
queue_key: str,
group: str,
message_id: str,
fields: dict[str, str],
semaphore: asyncio.Semaphore,
) -> None:
job_id = fields["job_id"]
try:
state = await self.manager.get_status(job_id)
if not state or state["status"] == "cancelled":
await self.manager.redis.xack(queue_key, group, message_id)
return
await self.manager.redis.hincrby(self.manager._metrics_key(job_type), "queued_count", -1)
await self.manager.redis.hincrby(self.manager._metrics_key(job_type), "running_count", 1)
await self.manager._set_state(job_id, {
"job_id": job_id,
"request_id": state["request_id"],
"type": job_type,
"status": "running",
"updated_at": _now_ms(),
"created_at": state.get("created_at", _now_ms()),
"cancel_requested": "1" if state.get("cancel_requested") else "0",
"error": "",
})
metrics = await self.manager._metrics(job_type)
await self.manager._emit_event(job_id, "started", {"job_id": job_id, "type": job_type, "status": "running", **metrics})
payload = _json_loads(fields["payload"], {})
async def emit(event: str, data: dict[str, Any]) -> None:
live_state = await self.manager.get_status(job_id) or {"status": "running"}
live_metrics = await self.manager._metrics(job_type)
await self.manager._emit_event(job_id, event, {"job_id": job_id, "type": job_type, "status": live_state["status"], **live_metrics, **data})
def is_cancelled() -> bool:
task = self.running_tasks.get(job_id)
return bool(task and task.cancelled())
result = await self.manager.handlers[job_type](payload, emit, is_cancelled)
current = await self.manager.get_status(job_id)
if current and current["status"] == "cancelled":
return
await self.manager._set_state(job_id, {
"job_id": job_id,
"request_id": state["request_id"],
"type": job_type,
"status": "completed",
"updated_at": _now_ms(),
"created_at": state.get("created_at", _now_ms()),
"cancel_requested": "0",
"error": "",
"result": _json_dumps(result),
})
metrics = await self.manager._metrics(job_type)
await self.manager._emit_event(job_id, "done", {"job_id": job_id, "type": job_type, "status": "completed", "result": result, **metrics})
await self.manager.redis.xack(queue_key, group, message_id)
except asyncio.CancelledError:
await self.manager.redis.hset(self.manager._state_key(job_id), mapping={"status": "cancelled", "cancel_requested": "1", "updated_at": _now_ms()})
metrics = await self.manager._metrics(job_type)
await self.manager._emit_event(job_id, "cancelled", {"job_id": job_id, "type": job_type, "status": "cancelled", **metrics})
await self.manager.redis.xack(queue_key, group, message_id)
raise
except Exception as exc:
logger.exception("redis worker failed job_id=%s type=%s", job_id, job_type)
state = await self.manager.get_status(job_id)
request_id = state["request_id"] if state else job_id
await self.manager._set_state(job_id, {
"job_id": job_id,
"request_id": request_id,
"type": job_type,
"status": "failed",
"updated_at": _now_ms(),
"created_at": state.get("created_at", _now_ms()) if state else _now_ms(),
"cancel_requested": "0",
"error": str(exc),
})
metrics = await self.manager._metrics(job_type)
await self.manager._emit_event(job_id, "error", {"job_id": job_id, "type": job_type, "status": "failed", "error": str(exc), **metrics})
await self.manager.redis.xack(queue_key, group, message_id)
finally:
self.running_tasks.pop(job_id, None)
await self.manager.redis.hincrby(self.manager._metrics_key(job_type), "running_count", -1)
semaphore.release()
_job_manager: BaseJobManager | None = None
def get_job_manager() -> BaseJobManager:
global _job_manager
if _job_manager is None:
backend = get_job_backend_name()
if backend == "redis":
_job_manager = RedisJobManager()
else:
_job_manager = InMemoryJobManager()
return _job_manager
def reset_job_manager() -> None:
global _job_manager
manager = _job_manager
if manager is not None:
close = getattr(manager, "close", None)
if close is not None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
try:
asyncio.run(close())
except Exception:
pass
else:
loop.create_task(close())
_job_manager = None
+115 -29
View File
@@ -2,6 +2,7 @@ import os
import time
import logging
import asyncio
import inspect
import json
import base64
from datetime import datetime
@@ -18,6 +19,9 @@ load_dotenv()
LLM_BASE_URL = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1/')
LLM_API_KEY = os.getenv('LLM_API_KEY', 'ollama')
# Auth headers for upstream LLM service (OpenAI-compatible Bearer token)
LLM_HEADERS = {'Authorization': f'Bearer {LLM_API_KEY}'}
# Model names (backward compat: fall back to OLLAMA_MODEL if LLM_MODEL not set)
_raw_model = os.getenv('LLM_MODEL') or os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
LLM_MODEL = _raw_model.strip() if _raw_model else 'gpt-oss:20b'
@@ -40,6 +44,51 @@ LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_TIMEOUT", "600"))
OCR_TIMEOUT = int(os.getenv("LLM_OCR_TIMEOUT", "600"))
async def _maybe_await(value):
if inspect.isawaitable(value):
return await value
return value
class _AsyncClientContext:
def __init__(self, client):
self.client = client
async def __aenter__(self):
return self.client
async def __aexit__(self, *args):
close = getattr(self.client, "aclose", None) or getattr(self.client, "close", None)
if close:
await _maybe_await(close())
async def _create_async_client(timeout: httpx.Timeout):
client = await _maybe_await(
httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=timeout)
)
if hasattr(client, "__aenter__"):
return client
return _AsyncClientContext(client)
async def _client_post(client, url: str, payload: dict):
try:
return await client.post(url, json=payload)
except TypeError as exc:
raw_post = getattr(type(client), "__dict__", {}).get("post")
if raw_post is None or "multiple values for argument" not in str(exc):
raise
return await raw_post(url, json=payload)
async def _stream_line_iterator(response):
lines = await _maybe_await(response.aiter_lines())
if hasattr(lines, "__aiter__"):
return lines.__aiter__()
return lines
logger = logging.getLogger('llm')
@@ -73,6 +122,8 @@ def _build_chat_payload(
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
prefill: str | None = None,
max_output_tokens: int | None = None,
) -> dict:
messages = []
sys_prompt = _resolve_system_prompt(system_prompt)
@@ -80,15 +131,21 @@ def _build_chat_payload(
messages.append({'role': 'system', 'content': sys_prompt})
messages.append({'role': 'user', 'content': prompt})
if prefill:
messages.append({'role': 'assistant', 'content': prefill})
options = {'temperature': temperature}
if thinking:
options['think'] = thinking
payload = {
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
'messages': messages,
'stream': False,
'options': options,
}
options = {'temperature': temperature}
if thinking:
payload['options'] = {'temperature': temperature, 'think': thinking}
if max_output_tokens and max_output_tokens > 0:
payload['max_tokens'] = int(max_output_tokens)
return payload
@@ -101,6 +158,8 @@ def _build_chat_stream_payload(
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
prefill: str | None = None,
max_output_tokens: int | None = None,
) -> dict:
messages = []
sys_prompt = _resolve_system_prompt(system_prompt)
@@ -108,15 +167,21 @@ def _build_chat_stream_payload(
messages.append({'role': 'system', 'content': sys_prompt})
messages.append({'role': 'user', 'content': prompt})
if prefill:
messages.append({'role': 'assistant', 'content': prefill})
options = {'temperature': temperature}
if thinking:
options['think'] = thinking
payload = {
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
'messages': messages,
'stream': True,
'options': options,
}
options = {'temperature': temperature}
if thinking:
payload['options'] = {'temperature': temperature, 'think': thinking}
if max_output_tokens and max_output_tokens > 0:
payload['max_tokens'] = int(max_output_tokens)
return payload
@@ -145,6 +210,8 @@ async def call_ollama(
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
prefill: str | None = None,
max_output_tokens: int | None = None,
) -> dict:
"""Call OpenAI-compatible chat completions (non-streaming) and return content/thinking."""
start = time.perf_counter()
@@ -161,15 +228,16 @@ async def call_ollama(
payload = _build_chat_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking, model=model, use_pro_model=use_pro_model,
thinking=thinking, model=model, use_pro_model=use_pro_model, prefill=prefill,
max_output_tokens=max_output_tokens,
)
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
async with await _create_async_client(http_timeout) as client:
resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=COMPLETION_TIMEOUT,
_client_post(client, '/chat/completions', payload), timeout=COMPLETION_TIMEOUT,
)
resp.raise_for_status()
@@ -229,6 +297,8 @@ async def stream_ollama(
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
prefill: str | None = None,
max_output_tokens: int | None = None,
) -> AsyncIterator[str]:
"""Stream text deltas from OpenAI-compatible chat completions."""
start = time.perf_counter()
@@ -246,19 +316,20 @@ async def stream_ollama(
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking, model=model, use_pro_model=use_pro_model,
thinking=thinking, model=model, use_pro_model=use_pro_model, prefill=prefill,
max_output_tokens=max_output_tokens,
)
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
async with await _create_async_client(http_timeout) as client:
try:
async with client.stream('POST', '/chat/completions', json=payload) as response:
response.raise_for_status()
await _maybe_await(response.raise_for_status())
deadline = time.perf_counter() + COMPLETION_TIMEOUT
line_iterator = response.aiter_lines().__aiter__()
line_iterator = await _stream_line_iterator(response)
while True:
remaining = deadline - time.perf_counter()
@@ -352,7 +423,9 @@ async def stream_ollama_events(
model: str | None = None,
use_pro_model: bool = False,
enable_thinking: bool = True,
prefill: str | None = None,
timeout: float | None = None,
max_output_tokens: int | None = None,
) -> AsyncIterator[tuple[Literal['thinking', 'content'], str]]:
"""Stream (event_type, payload) tuples from OpenAI-compatible chat completions."""
start = time.perf_counter()
@@ -370,7 +443,8 @@ async def stream_ollama_events(
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking if enable_thinking else None, model=model, use_pro_model=use_pro_model,
thinking=thinking if enable_thinking else None, model=model, use_pro_model=use_pro_model, prefill=prefill,
max_output_tokens=max_output_tokens,
)
effective_timeout = timeout if timeout is not None else COMPLETION_TIMEOUT
@@ -378,13 +452,13 @@ async def stream_ollama_events(
sent_thinking = False
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
async with await _create_async_client(http_timeout) as client:
try:
async with client.stream('POST', '/chat/completions', json=payload) as response:
response.raise_for_status()
await _maybe_await(response.raise_for_status())
deadline = time.perf_counter() + effective_timeout
line_iterator = response.aiter_lines().__aiter__()
line_iterator = await _stream_line_iterator(response)
while True:
remaining = deadline - time.perf_counter()
@@ -477,39 +551,51 @@ async def stream_ollama_events(
)
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
"""OCR via VLM using OpenAI-compatible vision API (image_url content part)."""
async def call_vlm_ocr(
media_bytes: bytes,
language: str = 'auto',
*,
mime_type: str = 'image/png',
media_type: str = 'image',
) -> str:
"""OCR via VLM using OpenAI-compatible multimodal API."""
start = time.perf_counter()
start_dt = datetime.now()
logger.info(
'[VLM][ocr] request model=%s base_url=%s image_bytes=%d language=%s',
VLM_MODEL, LLM_BASE_URL, len(image_bytes), language,
'[VLM][ocr] request model=%s base_url=%s media_type=%s media_bytes=%d language=%s mime=%s',
VLM_MODEL, LLM_BASE_URL, media_type, len(media_bytes), language, mime_type,
)
image_b64 = base64.b64encode(image_bytes).decode('ascii')
media_b64 = base64.b64encode(media_bytes).decode('ascii')
content_part_type = 'video_url' if media_type == 'video' else 'image_url'
url_key = 'video_url' if media_type == 'video' else 'image_url'
payload = {
'model': VLM_MODEL,
'messages': [{
'role': 'user',
'content': [
{'type': 'text', 'text': get_vlm_ocr_prompt()},
{'type': 'text', 'text': f"{get_vlm_ocr_prompt()}\n\nTarget language hint: {language or 'auto'}"},
{
'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{image_b64}'},
'type': content_part_type,
url_key: {'url': f'data:{mime_type or "application/octet-stream"};base64,{media_b64}'},
},
],
}],
'stream': False,
'options': {
'temperature': 0,
'think': False,
},
}
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
async with await _create_async_client(http_timeout) as client:
resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=OCR_TIMEOUT,
_client_post(client, '/chat/completions', payload), timeout=OCR_TIMEOUT,
)
resp.raise_for_status()
+80
View File
@@ -0,0 +1,80 @@
from dataclasses import dataclass
from typing import Any
from risk_config import RiskConfig
@dataclass(frozen=True)
class LLMPolicy:
job_type: str
model: str
profile: str
max_input_chars: int
max_output_tokens: int
temperature: float
thinking: str | None
def _normalize_thinking(value: str | None, *, allow_high: bool) -> str | None:
candidate = (value or "").strip().lower()
if candidate in {"", "none", "off"}:
return None
if candidate not in {"low", "medium", "high"}:
return "low"
if candidate == "high" and not allow_high:
return "medium"
return candidate
def resolve_llm_policy(job_type: str, request_payload: dict[str, Any], config: RiskConfig) -> LLMPolicy:
if job_type == "completion":
return LLMPolicy(
job_type=job_type,
model=config.completion_model,
profile="completion",
max_input_chars=config.completion_max_input_chars,
max_output_tokens=config.completion_max_output_tokens,
temperature=config.completion_temperature,
thinking=_normalize_thinking(request_payload.get("model_thinking"), allow_high=False),
)
if job_type == "pro_completion":
return LLMPolicy(
job_type=job_type,
model=config.pro_model,
profile="pro",
max_input_chars=config.pro_max_input_chars,
max_output_tokens=config.pro_max_output_tokens,
temperature=config.pro_temperature,
thinking=_normalize_thinking(request_payload.get("pro_thinking"), allow_high=True) or "medium",
)
if job_type == "web_search":
return LLMPolicy(
job_type=job_type,
model=config.web_search_model,
profile="completion",
max_input_chars=config.web_search_max_input_chars,
max_output_tokens=config.web_search_max_output_tokens,
temperature=config.web_search_temperature,
thinking="low",
)
if job_type == "compress":
return LLMPolicy(
job_type=job_type,
model=config.completion_model,
profile="completion",
max_input_chars=config.compress_max_input_chars,
max_output_tokens=config.compress_max_output_tokens,
temperature=0.2,
thinking="low",
)
if job_type == "ocr":
return LLMPolicy(
job_type=job_type,
model=config.vision_model,
profile="vision",
max_input_chars=config.ocr_max_input_bytes,
max_output_tokens=config.completion_max_output_tokens,
temperature=0.0,
thinking=None,
)
raise ValueError(f"unsupported llm policy job type: {job_type}")
+810 -330
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import subprocess
import tempfile
VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v", ".ogv"}
def is_video_filename(filename: str = "", mime_type: str = "") -> bool:
ext = os.path.splitext(filename or "")[1].lower()
mime = (mime_type or "").strip().lower()
return ext in VIDEO_EXTENSIONS or mime.startswith("video/")
def extract_audio_wav_bytes(input_path: str) -> bytes:
if not input_path or not os.path.exists(input_path):
raise FileNotFoundError("输入媒体文件不存在")
fd, output_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
subprocess.run(
[
"ffmpeg",
"-y",
"-i",
input_path,
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
output_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
with open(output_path, "rb") as handle:
return handle.read()
finally:
if os.path.exists(output_path):
os.unlink(output_path)
+1 -1
View File
@@ -5,5 +5,5 @@ from pydantic import BaseModel
class UserPreferences(BaseModel):
"""用户偏好设置"""
language: str = "auto"
currency: str = "auto"
country: str = "auto"
timezone: str = "auto"
+26 -38
View File
@@ -16,6 +16,7 @@ from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import stream_ollama_events
from models import UserPreferences
from prompt import build_pro_completion_prompts
logger = logging.getLogger("api.pro")
@@ -25,6 +26,7 @@ PRO_MAX_CONCURRENCY = max(1, int(os.getenv("PRO_MAX_CONCURRENCY", "1")))
PRO_QUEUE_MAX_SIZE = max(0, int(os.getenv("PRO_QUEUE_MAX_SIZE", "5")))
PRO_STATUS_RETENTION_SECONDS = float(os.getenv("PRO_STATUS_RETENTION_SECONDS", "600"))
PRO_CANCEL_ACK_TIMEOUT = 5.0
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
PUBLIC_PRO_ERROR = "PRO generation failed. Please retry or adjust the instruction."
@@ -125,44 +127,19 @@ def _build_pro_prompts(
suffix: str,
language_id: str,
instruction: str,
pro_thinking: str = "medium",
location: str = "",
preferences: UserPreferences | None = None,
) -> tuple[str, str]:
safe_language = (language_id or "markdown").strip() or "markdown"
safe_instruction = (instruction or "").strip()
preference_lines: list[str] = []
if preferences:
if preferences.language and preferences.language != "auto":
preference_lines.append(f"- Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto":
preference_lines.append(f"- Preferred currency: {preferences.currency}")
if preferences.timezone and preferences.timezone != "auto":
preference_lines.append(f"- Timezone: {preferences.timezone}")
if location:
preference_lines.append(f"- Location hint: {location}")
system_prompt = f"""You edit Markdown documents.
Return only the Markdown text to insert at the cursor.
Do not explain, analyze, label the answer, or wrap the whole answer in a code fence.
Match the document language, style, and Markdown structure.
Language: {safe_language}."""
preferences_text = "\n".join(preference_lines) if preference_lines else "- none"
instruction_text = safe_instruction or "Continue the Markdown naturally."
user_prompt = f"""Instruction:
{instruction_text}
User preferences:
{preferences_text}
Markdown before cursor:
{prefix}
Markdown after cursor:
{suffix}
Write only the Markdown that belongs at the cursor."""
return system_prompt.strip(), user_prompt.strip()
return build_pro_completion_prompts(
prefix=prefix,
suffix=suffix,
instruction=instruction,
language_id=language_id,
location=location,
pro_thinking_level=pro_thinking,
preferences=preferences,
)
def _get_client_ip(request: Request) -> str:
@@ -237,6 +214,7 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
suffix=suffix,
language_id=req.languageId,
instruction=req.instruction,
pro_thinking=req.pro_thinking,
location=location,
preferences=req.user_preferences,
)
@@ -283,15 +261,18 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
enable_thinking=True,
timeout=PRO_COMPLETION_TIMEOUT,
):
# Handle 'thinking' event - just update state, don't accumulate
if event_type == "thinking":
await _send_sse_event(event_queue, "thinking", {"request_id": request_id})
continue
# Handle 'chunk' event - accumulate content
if not payload:
continue
chunks.append(payload)
await _send_sse_event(event_queue, "chunk", {"delta": payload, "request_id": request_id})
# Handle 'done' event - return full content
content = "".join(chunks)
async with PRO_STATES_LOCK:
if state.cancel_requested:
@@ -327,11 +308,17 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
async def event_stream():
try:
while True:
item = await event_queue.get()
try:
item = await asyncio.wait_for(event_queue.get(), timeout=STREAM_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
if item is None:
break
event_name, data = item
yield f"event: {event_name}\ndata: {data}\n\n"
if event_name in {"done", "error", "cancelled"}:
break
except asyncio.CancelledError:
async with PRO_STATES_LOCK:
state.request_cancel()
@@ -347,9 +334,10 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+121 -3
View File
@@ -1,9 +1,16 @@
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
import re
from typing import Tuple
from models import UserPreferences
from prompts import get_language_guidance_map, get_system_prompt_template, get_inline_examples
from prompts import (
get_inline_examples,
get_inline_examples_pro,
get_language_guidance_map,
get_system_prompt_pro_template,
get_system_prompt_template,
)
def _get_current_datetime(timezone_pref: str = "auto") -> str:
@@ -39,6 +46,16 @@ def _get_current_datetime(timezone_pref: str = "auto") -> str:
)
def _normalize_preferences(preferences: UserPreferences | Mapping | None) -> UserPreferences | None:
if preferences is None:
return None
if isinstance(preferences, UserPreferences):
return preferences
if isinstance(preferences, Mapping):
return UserPreferences(**preferences)
return preferences
def _sanitize_language_id(language_id: str) -> str:
if not language_id:
return "markdown"
@@ -294,6 +311,17 @@ def build_inline_system_prompt(language_id: str = "markdown") -> str:
_INLINE_EXAMPLES = get_inline_examples()
_PRO_INLINE_EXAMPLES = get_inline_examples_pro()
def build_pro_system_prompt(language_id: str = "markdown") -> str:
safe_language_id = _canonical_language_id(language_id)
language_guidance = _language_guidance(safe_language_id)
template = get_system_prompt_pro_template()
system_prompt = template.replace("{language_id}", safe_language_id)
if language_guidance:
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
return system_prompt.strip()
def build_completion_prompts(
@@ -304,6 +332,7 @@ def build_completion_prompts(
thinking_level: str = "low",
preferences: UserPreferences | None = None,
) -> Tuple[str, str, str]:
preferences = _normalize_preferences(preferences)
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix)
@@ -326,8 +355,8 @@ def build_completion_prompts(
if preferences:
if preferences.language and preferences.language != "auto":
pref_info.append(f"Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto":
pref_info.append(f"Preferred currency: {preferences.currency}")
if preferences.country and preferences.country != "auto":
pref_info.append(f"Preferred country: {preferences.country}")
preferences_instruction = "\n".join(pref_info)
if preferences_instruction:
@@ -392,3 +421,92 @@ def build_prompt(
preferences=preferences,
)
return user_prompt
def build_pro_completion_prompts(
prefix: str,
suffix: str,
instruction: str = "",
language_id: str = "markdown",
location: str = "",
pro_thinking_level: str = "medium",
preferences: UserPreferences | None = None,
) -> Tuple[str, str]:
preferences = _normalize_preferences(preferences)
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix)
recent_suffix = _normalize_newlines(recent_suffix)
cursor_fence_language = _active_fence_language(recent_prefix)
cursor_in_fenced_code_block = cursor_fence_language != "none"
mermaid_context = _is_mermaid_context(
recent_prefix, recent_suffix, cursor_fence_language
)
prefix_ends_with_newline = recent_prefix.endswith("\n")
suffix_starts_with_newline = recent_suffix.startswith("\n")
tz_pref = preferences.timezone if preferences else "auto"
current_time = _get_current_datetime(tz_pref)
location_info = f"\nUser location: {location}" if location else ""
pref_info = []
if preferences:
if preferences.language and preferences.language != "auto":
pref_info.append(f"Preferred language: {preferences.language}")
if preferences.country and preferences.country != "auto":
pref_info.append(f"Preferred country: {preferences.country}")
if preferences.timezone and preferences.timezone != "auto":
pref_info.append(f"Preferred timezone: {preferences.timezone}")
preferences_instruction = "\n".join(pref_info)
if preferences_instruction:
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
instruction_text = (instruction or "").strip() or "Continue the Markdown naturally."
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
PRO_MODE: true
PRO_THINKING_LEVEL: {pro_thinking_level}
Editor language: {safe_language_id}
=== STATE FLAGS ===
- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"}
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
=== PRO INSTRUCTION (HIGHEST PRIORITY) ===
{instruction_text}
=== PRO TASK ===
Produce the best insertion text between PREFIX and SUFFIX for [PRO] mode.
Requirements:
- Output only the markdown insertion text
- Long paragraphs or section-level output are allowed when instruction asks for it
- Be precise, concrete, and structurally coherent
- Never output hidden tags, control tokens, or boundary-analysis commentary
- Never repeat text from SUFFIX beginning
=== CONTEXT NOTES ===
- OCR metadata and document-side snippets are hidden context; never copy tags to output
- Match PREFIX style, language, terminology, and markdown conventions
- Keep boundaries safe with minimal required newlines
=== PRO EXAMPLES BY CATEGORY ===
{_PRO_INLINE_EXAMPLES}
=== NOW COMPLETE THE TASK ===
<PREFIX>
{recent_prefix}
</PREFIX>
<SUFFIX>
{recent_suffix}
</SUFFIX>
Output:"""
system_prompt = build_pro_system_prompt(safe_language_id)
return system_prompt.strip(), user_prompt.strip()
+8
View File
@@ -40,5 +40,13 @@ def get_inline_examples() -> str:
return _prompts.get("inline_examples", {}).get("content", "")
def get_system_prompt_pro_template() -> str:
return _prompts.get("system_prompt_pro", {}).get("template", "")
def get_inline_examples_pro() -> str:
return _prompts.get("inline_examples_pro", {}).get("content", "")
def get_vlm_ocr_prompt() -> str:
return _prompts.get("vlm_ocr", {}).get("prompt", "")
+3
View File
@@ -0,0 +1,3 @@
{
"content": "=== PRO CATEGORY A: CONTINUATION AND EXPANSION ===\n\n[PRO-EX01] Continue naturally without suffix repetition\n<PREFIX>Project update: This week we completed </PREFIX>\n<SUFFIX>and started preparing the release checklist.</SUFFIX>\nExpected OUTPUT:\nbackend integration tests\nWRONG: and started preparing (repeats suffix start)\n\n[PRO-EX02] Long paragraph expansion allowed\nINSTRUCTION: Expand into a fuller paragraph with concrete details.\n<PREFIX>Our migration reduced incidents.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nIt also improved deployment confidence by cutting rollback frequency and clarifying ownership for each service boundary, which made post-release diagnosis significantly faster.\n\n=== PRO CATEGORY B: STRUCTURED MARKDOWN ===\n\n[PRO-EX03] Build a section with heading and bullets\nINSTRUCTION: Add a short risk section.\n<PREFIX>## Launch Plan\nCurrent status is green.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n### Risks\n- Third-party API latency may delay webhook retries.\n- Data backfill window could overlap with peak traffic.\n\n[PRO-EX04] Preserve list numbering continuity\n<PREFIX>1. Prepare schema\n2. Run dry-run\n3. </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\nValidate production metrics and sign off\n\n[PRO-EX05] Keep table shape valid\n<PREFIX>| Metric | Before | After |\n| --- | --- | --- |\n| P95 latency | 420ms | </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n260ms |\n\n=== PRO CATEGORY C: CODE AND TECHNICAL CONTEXT ===\n\n[PRO-EX06] Inside code fence: output code only\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef build_payload(user_id):\n return </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\n{\"id\": user_id, \"active\": True}\n\n[PRO-EX07] Outside code fence: include fenced block when instruction asks code\nCURSOR_IN_FENCED_CODE_BLOCK=false\nINSTRUCTION: Show a minimal SQL query.\n<PREFIX>Fetch active users:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n```sql\nSELECT id, email\nFROM users\nWHERE active = TRUE;\n```\n\n=== PRO CATEGORY D: MATH AND MERMAID ===\n\n[PRO-EX08] Inline math remains inline\n<PREFIX>The expected value is </PREFIX>\n<SUFFIX> under this distribution.</SUFFIX>\nExpected OUTPUT:\n$\\mu$\n\n[PRO-EX09] Mermaid inside mermaid fence\nCURSOR_FENCE_LANGUAGE=mermaid\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```mermaid\nflowchart TD\nA[Input] --> </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nB{Validated?}\nB -->|Yes| C[Persist]\n\n[PRO-EX10] Mermaid outside fence with context\nMERMAID_CONTEXT=true\nCURSOR_IN_FENCED_CODE_BLOCK=false\n<PREFIX>Show the pipeline as a diagram.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n```mermaid\nflowchart LR\nQueue --> Worker --> Storage\n```\n\n=== PRO CATEGORY E: INSTRUCTION-FIRST REWRITE ===\n\n[PRO-EX11] Rewrite style per instruction\nINSTRUCTION: Rewrite as concise executive tone in Chinese.\n<PREFIX>这个方案看起来不错,但是细节很多,可能会拖慢推进。</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n该方案方向正确,但需聚焦关键路径并压缩实现范围,以保障交付节奏。\n\n[PRO-EX12] Add constrained output length\nINSTRUCTION: Add one sentence under 25 words.\n<PREFIX>结论:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n先完成最小可用版本,再按风险优先级迭代。\n\n=== PRO CATEGORY F: HIDDEN CONTEXT SAFETY ===\n\n[PRO-EX13] Never leak OCR tags\n<PREFIX>![board](a.png) <OCR:roadmap Q3 launch>\nTimeline:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nQ3 launch with weekly checkpoints and ownership tracking.\nWRONG: <OCR:roadmap Q3 launch>\n\n=== PRO CATEGORY G: BOUNDARY PRECISION ===\n\n[PRO-EX14] Add leading newline when needed\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>Action items:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n- Confirm rollout window\n- Notify on-call rotation\n\n[PRO-EX15] Do not break upcoming heading\nPREFIX_ENDS_WITH_NEWLINE=false\nSUFFIX_STARTS_WITH_NEWLINE=false\n<PREFIX>Summary complete.</PREFIX>\n<SUFFIX>## Next Steps</SUFFIX>\nExpected OUTPUT:\n\n\n=== PRO CATEGORY H: MIXED STRUCTURE ===\n\n[PRO-EX16] Combine short prose + list + code block\nINSTRUCTION: Add a short explanation, then checklist, then shell command.\n<PREFIX>Deployment guide draft:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nUse the following sequence to reduce release risk:\n- Verify migrations on staging\n- Freeze non-critical merges\n- Capture rollback snapshot\n\n```bash\n./scripts/deploy.sh --env prod\n```"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes, no analysis.\n\nNever output internal reasoning, chain-of-thought, boundary checks, or deliberation. Never output chat/template artifacts such as assistant, final, channel, <|start|>, <|end|>, <|fim_prefix|>, <|fim_suffix|>, or <|fim_middle|>.\n\nCONTEXT FLAGS:\n- CURSOR_IN_FENCED_CODE_BLOCK tells whether the cursor is inside a code fence.\n- CURSOR_FENCE_LANGUAGE gives the active fence language, or none.\n- PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE describe the insertion boundary.\n- MERMAID_CONTEXT tells whether Mermaid syntax is likely expected.\n\nSPECIALIZED RULES:\n- If CURSOR_IN_FENCED_CODE_BLOCK=true: output only code lines, no triple backticks.\n- If CURSOR_IN_FENCED_CODE_BLOCK=false and a code block is needed: use a fenced block with a language tag, e.g. ```{language}.\n- Inline math must use $...$; block math must use $$...$$.\n- Inside latex/tex/katex fences, output raw LaTeX only.\n- If CURSOR_FENCE_LANGUAGE=mermaid: output Mermaid syntax only, no backticks or prose.\n- If MERMAID_CONTEXT=true outside a fence: output a complete ```mermaid fenced block only when the surrounding text asks for a diagram.\n\nMARKDOWN AND BOUNDARIES:\n- Use actual line breaks, never spelled-out escape sequences, unless the document text itself needs them.\n- Match PREFIX tone, style, indentation, list/table structure, and language.\n- Never repeat text from the beginning of SUFFIX.\n- If separation is needed, put the needed real newline directly in the insertion text without explaining it.\n\nPREFILL:\n- The prompt may place a short tail of PREFIX immediately after <|fim_middle|> to make continuation natural.\n- Continue from that PREFILL. Do not describe it or output control markers.\n\nHIDDEN CONTEXT:\n- OCR metadata like <OCR:...> and document context are hidden context.\n- Use hidden context only as a semantic hint; never copy hidden tags to output."
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\n\nNever output chat/template artifacts such as assistant, final, channel, <|fim_prefix|>, <|fim_suffix|>, or <|fim_middle|>.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Separate code lines with actual newline characters\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Use actual line breaks in output, not spelled-out escape sequences, unless the surrounding content explicitly needs that text\n- A single line break usually continues the current block\n- A blank line starts a new paragraph or block\n- Use blank lines for: new paragraphs, before headings, starting lists/tables\n- Use single line breaks for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use actual newline characters freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT on a new line\n\n- If SUFFIX lacks needed newline: end OUTPUT with a trailing line break\n\n- Common cases requiring a leading line break:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring a trailing line break:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
}
+3
View File
@@ -0,0 +1,3 @@
{
"template": "You are the [PRO] model for LLM-IN-TEXT, specializing in high-precision markdown insertion for a {language_id} editor.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nPRO CORE PRINCIPLE:\n- Output insertion text only. No explanations, no analysis, no labels, no wrapper quotes.\n- Never wrap the entire answer in an outer ```markdown code fence; only use fenced code blocks when the inserted content itself requires code.\n- Never output chain-of-thought or internal reasoning.\n- Never output control markers like <|fim_prefix|>, <|fim_suffix|>, <|fim_middle|>, assistant, final, channel.\n\nPRO MODE INTENT:\n- This is PRO_MODE=true. You may produce longer, structured markdown when instruction requires it.\n- Prioritize instruction fidelity first, then boundary safety, then style continuity.\n- If instruction is vague, continue naturally with concrete and useful content.\n\nBOUNDARY AND CONTEXT RULES:\n- Respect CURSOR_IN_FENCED_CODE_BLOCK, CURSOR_FENCE_LANGUAGE, MERMAID_CONTEXT, PREFIX_ENDS_WITH_NEWLINE, and SUFFIX_STARTS_WITH_NEWLINE.\n- Never repeat text from the beginning of SUFFIX.\n- Use minimum necessary newlines to avoid boundary collision.\n- Match PREFIX tone, language, and formatting conventions.\n\nSYNTAX PRIORITY:\n- Code block contexts must keep valid syntax and indentation.\n- Math must use $...$ for inline and $$...$$ for blocks unless inside latex fences.\n- Mermaid contexts must output valid mermaid statements; do not duplicate fences when already inside one.\n\nHIDDEN CONTEXT SAFETY:\n- OCR metadata and document-side context are hidden hints only.\n- Never copy hidden tags (e.g., <OCR:...>) into output.\n\nQUALITY BAR FOR PRO:\n- Prefer specific, information-dense output over generic filler.\n- For structured requests, preserve headings/list hierarchy and produce coherent section flow.\n- Keep output directly insertable without post-edit cleanups."
}
+17
View File
@@ -0,0 +1,17 @@
fastapi>=0.95.0
uvicorn[standard]>=0.23.0
pydantic>=1.10.0
httpx>=0.24.0
redis>=5.0.0
psycopg[binary]>=3.2.0
python-multipart>=0.0.9
python-dotenv>=1.0.0
markitdown>=0.1.1
geoip2>=4.8.0
numpy>=1.26.0
torch>=2.2.0
soundfile>=0.12.1
scipy>=1.13.0
qwen-tts
modelscope>=1.18.0
faster-whisper>=1.1.0
+5
View File
@@ -2,6 +2,10 @@ fastapi>=0.95.0
uvicorn[standard]>=0.23.0
pydantic>=1.10.0
httpx>=0.24.0
redis>=5.0.0
psycopg[binary]>=3.2.0
python-multipart>=0.0.9
python-dotenv>=1.0.0
numpy>=1.23.0
soundfile>=0.10.3
@@ -17,3 +21,4 @@ mlx-audio>=0.4.3
# testing
pytest>=7.0.0
pytest-cov>=4.1.0
+148
View File
@@ -0,0 +1,148 @@
import os
from dataclasses import dataclass
def _bool_env(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _int_env(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def _float_env(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def _str_env(name: str, default: str) -> str:
value = os.getenv(name)
if value is None:
return default
return value.strip() or default
def _optional_env(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
@dataclass(frozen=True)
class RiskConfig:
cors_allow_origins: tuple[str, ...]
session_cookie_name: str
session_cookie_secure: bool
session_cookie_samesite: str
session_cookie_domain: str | None
session_cookie_max_age: int
session_cookie_path: str
session_rotation_seconds: int
api_window_seconds: int
api_soft_limit_per_window: int
api_hard_limit_per_window: int
llm_window_seconds: int
llm_soft_limit_per_window: int
llm_hard_limit_per_window: int
session_concurrency_limit: int
global_concurrency_limit: int
daily_budget_global_usd: float
daily_budget_session_usd: float
daily_budget_ip_usd: float
single_request_max_cost_usd: float
delay_step_ms: int
delay_cap_ms: int
model_circuit_breaker_failures: int
model_circuit_ttl_seconds: int
enforce_redis_fail_closed: bool
completion_model: str
pro_model: str
vision_model: str
completion_max_input_chars: int
completion_max_output_tokens: int
completion_temperature: float
pro_max_input_chars: int
pro_max_output_tokens: int
pro_temperature: float
web_search_model: str
web_search_max_input_chars: int
web_search_max_output_tokens: int
web_search_temperature: float
compress_max_input_chars: int
compress_max_output_tokens: int
ocr_max_input_bytes: int
completion_input_cost_per_1k: float
completion_output_cost_per_1k: float
pro_input_cost_per_1k: float
pro_output_cost_per_1k: float
vision_input_cost_per_1k: float
vision_output_cost_per_1k: float
def load_risk_config() -> RiskConfig:
raw_origins = _str_env(
"CORS_ALLOW_ORIGINS",
"https://chat.imageteach.tech,http://localhost:8080,http://127.0.0.1:8080,http://localhost:5173,http://127.0.0.1:5173",
)
cors_allow_origins = tuple(
origin.strip() for origin in raw_origins.split(",") if origin.strip()
)
return RiskConfig(
cors_allow_origins=cors_allow_origins,
session_cookie_name=_str_env("SESSION_COOKIE_NAME", "llm_anonymous_session"),
session_cookie_secure=_bool_env("SESSION_COOKIE_SECURE", True),
session_cookie_samesite=_str_env("SESSION_COOKIE_SAMESITE", "none"),
session_cookie_domain=_optional_env("SESSION_COOKIE_DOMAIN"),
session_cookie_max_age=_int_env("SESSION_COOKIE_MAX_AGE_SECONDS", 60 * 60 * 24 * 30),
session_cookie_path=_str_env("SESSION_COOKIE_PATH", "/"),
session_rotation_seconds=_int_env("SESSION_ROTATION_SECONDS", 60 * 60 * 24),
api_window_seconds=_int_env("RISK_API_WINDOW_SECONDS", 60),
api_soft_limit_per_window=_int_env("RISK_API_SOFT_LIMIT", 90),
api_hard_limit_per_window=_int_env("RISK_API_HARD_LIMIT", 180),
llm_window_seconds=_int_env("RISK_LLM_WINDOW_SECONDS", 600),
llm_soft_limit_per_window=_int_env("RISK_LLM_SOFT_LIMIT", 8),
llm_hard_limit_per_window=_int_env("RISK_LLM_HARD_LIMIT", 16),
session_concurrency_limit=_int_env("RISK_SESSION_CONCURRENCY_LIMIT", 2),
global_concurrency_limit=_int_env("RISK_GLOBAL_CONCURRENCY_LIMIT", 12),
daily_budget_global_usd=_float_env("RISK_DAILY_BUDGET_GLOBAL_USD", 20.0),
daily_budget_session_usd=_float_env("RISK_DAILY_BUDGET_SESSION_USD", 2.0),
daily_budget_ip_usd=_float_env("RISK_DAILY_BUDGET_IP_USD", 5.0),
single_request_max_cost_usd=_float_env("RISK_SINGLE_REQUEST_MAX_COST_USD", 0.8),
delay_step_ms=_int_env("RISK_DELAY_STEP_MS", 2500),
delay_cap_ms=_int_env("RISK_DELAY_CAP_MS", 30000),
model_circuit_breaker_failures=_int_env("RISK_MODEL_CIRCUIT_FAILURES", 8),
model_circuit_ttl_seconds=_int_env("RISK_MODEL_CIRCUIT_TTL_SECONDS", 300),
enforce_redis_fail_closed=_bool_env("RISK_ENFORCE_REDIS_FAIL_CLOSED", False),
completion_model=_str_env("RISK_COMPLETION_MODEL", os.getenv("LLM_MODEL", "gpt-4.1-mini")),
pro_model=_str_env("RISK_PRO_MODEL", os.getenv("PRO_LLM_MODEL", os.getenv("LLM_MODEL", "gpt-4.1"))),
vision_model=_str_env("RISK_VISION_MODEL", os.getenv("VLM_MODEL", "gpt-4.1-mini")),
completion_max_input_chars=_int_env("RISK_COMPLETION_MAX_INPUT_CHARS", 24000),
completion_max_output_tokens=_int_env("RISK_COMPLETION_MAX_OUTPUT_TOKENS", 768),
completion_temperature=_float_env("RISK_COMPLETION_TEMPERATURE", 0.4),
pro_max_input_chars=_int_env("RISK_PRO_MAX_INPUT_CHARS", 48000),
pro_max_output_tokens=_int_env("RISK_PRO_MAX_OUTPUT_TOKENS", 2048),
pro_temperature=_float_env("RISK_PRO_TEMPERATURE", 0.6),
web_search_model=_str_env("RISK_WEB_SEARCH_MODEL", os.getenv("LLM_MODEL", "gpt-4.1-mini")),
web_search_max_input_chars=_int_env("RISK_WEB_SEARCH_MAX_INPUT_CHARS", 128000),
web_search_max_output_tokens=_int_env("RISK_WEB_SEARCH_MAX_OUTPUT_TOKENS", 4096),
web_search_temperature=_float_env("RISK_WEB_SEARCH_TEMPERATURE", 0.4),
compress_max_input_chars=_int_env("RISK_COMPRESS_MAX_INPUT_CHARS", 128000),
compress_max_output_tokens=_int_env("RISK_COMPRESS_MAX_OUTPUT_TOKENS", 1536),
ocr_max_input_bytes=_int_env("RISK_OCR_MAX_INPUT_BYTES", 100 * 1024 * 1024),
completion_input_cost_per_1k=_float_env("RISK_COMPLETION_INPUT_COST_PER_1K", 0.0004),
completion_output_cost_per_1k=_float_env("RISK_COMPLETION_OUTPUT_COST_PER_1K", 0.0016),
pro_input_cost_per_1k=_float_env("RISK_PRO_INPUT_COST_PER_1K", 0.003),
pro_output_cost_per_1k=_float_env("RISK_PRO_OUTPUT_COST_PER_1K", 0.012),
vision_input_cost_per_1k=_float_env("RISK_VISION_INPUT_COST_PER_1K", 0.0008),
vision_output_cost_per_1k=_float_env("RISK_VISION_OUTPUT_COST_PER_1K", 0.0024),
)
+357
View File
@@ -0,0 +1,357 @@
import asyncio
import hashlib
import math
import os
import time
from dataclasses import dataclass
from datetime import date
from typing import Any
from risk_config import RiskConfig
try: # pragma: no cover
from redis import asyncio as redis_asyncio
except Exception: # pragma: no cover
redis_asyncio = None
def _now_ms() -> int:
return int(time.time() * 1000)
def _utc_day() -> str:
return date.today().isoformat()
def stable_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def estimate_tokens(text: str) -> int:
if not text:
return 0
ascii_chars = sum(1 for ch in text if ord(ch) < 128)
non_ascii = len(text) - ascii_chars
ascii_tokens = math.ceil(ascii_chars / 4)
non_ascii_tokens = math.ceil(non_ascii * 1.5)
return max(ascii_tokens + non_ascii_tokens, 1)
@dataclass(frozen=True)
class RiskIdentity:
request_id: str
session_hash: str
ip_hash: str
route: str
method: str
@dataclass(frozen=True)
class RiskDecision:
allowed: bool
status_code: int = 200
reason: str = ""
error_code: str = ""
retry_after_seconds: int = 0
delay_ms: int = 0
class RiskRejected(RuntimeError):
def __init__(self, decision: RiskDecision) -> None:
super().__init__(decision.reason or decision.error_code or "request rejected")
self.decision = decision
class BaseRiskBackend:
async def incr_window(self, key: str, ttl_seconds: int) -> int:
raise NotImplementedError
async def get_float(self, key: str) -> float:
raise NotImplementedError
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
raise NotImplementedError
async def get_int(self, key: str) -> int:
raise NotImplementedError
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
raise NotImplementedError
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
raise NotImplementedError
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
raise NotImplementedError
async def release_lock(self, key: str) -> None:
raise NotImplementedError
class InMemoryRiskBackend(BaseRiskBackend):
def __init__(self) -> None:
self.values: dict[str, tuple[float, float]] = {}
self.locks: dict[str, float] = {}
self.guard = asyncio.Lock()
def _purge(self) -> None:
now = time.time()
for key, (_, expires_at) in list(self.values.items()):
if expires_at and expires_at <= now:
self.values.pop(key, None)
for key, expires_at in list(self.locks.items()):
if expires_at <= now:
self.locks.pop(key, None)
async def incr_window(self, key: str, ttl_seconds: int) -> int:
async with self.guard:
self._purge()
value, _ = self.values.get(key, (0.0, 0.0))
next_value = int(value) + 1
self.values[key] = (float(next_value), time.time() + ttl_seconds)
return next_value
async def get_float(self, key: str) -> float:
async with self.guard:
self._purge()
return float(self.values.get(key, (0.0, 0.0))[0])
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
async with self.guard:
self._purge()
current, _ = self.values.get(key, (0.0, 0.0))
next_value = current + value
self.values[key] = (next_value, time.time() + ttl_seconds)
return next_value
async def get_int(self, key: str) -> int:
return int(await self.get_float(key))
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
async with self.guard:
self._purge()
self.values[key] = (float(value), time.time() + ttl_seconds)
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
async with self.guard:
self._purge()
self.values[key] = (float(value), time.time() + ttl_seconds)
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
async with self.guard:
self._purge()
if key in self.locks:
return False
self.locks[key] = time.time() + ttl_seconds
return True
async def release_lock(self, key: str) -> None:
async with self.guard:
self.locks.pop(key, None)
class RedisRiskBackend(BaseRiskBackend):
def __init__(self, redis_url: str) -> None:
if redis_asyncio is None:
raise RuntimeError("redis package is not installed")
self.redis = redis_asyncio.from_url(redis_url, encoding="utf-8", decode_responses=True)
async def incr_window(self, key: str, ttl_seconds: int) -> int:
value = await self.redis.incr(key)
if value == 1:
await self.redis.expire(key, ttl_seconds)
return int(value)
async def get_float(self, key: str) -> float:
value = await self.redis.get(key)
if value is None:
return 0.0
return float(value)
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
current = await self.get_float(key)
next_value = current + value
await self.redis.set(key, next_value, ex=ttl_seconds)
return next_value
async def get_int(self, key: str) -> int:
value = await self.redis.get(key)
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return int(float(value))
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
await self.redis.set(key, value, ex=ttl_seconds)
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
await self.redis.set(key, value, ex=ttl_seconds)
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
return bool(await self.redis.set(key, "1", ex=ttl_seconds, nx=True))
async def release_lock(self, key: str) -> None:
await self.redis.delete(key)
class RiskController:
def __init__(self, config: RiskConfig) -> None:
self.config = config
self.prefix = "llmtext:risk"
redis_url = os.getenv("REDIS_URL", "").strip()
if redis_url and redis_asyncio is not None:
self.backend: BaseRiskBackend = RedisRiskBackend(redis_url)
else:
self.backend = InMemoryRiskBackend()
def _api_key(self, identity: RiskIdentity, scope: str) -> str:
return f"{self.prefix}:api:{scope}:{identity.session_hash}:{identity.ip_hash}"
def _llm_key(self, identity: RiskIdentity, scope: str) -> str:
return f"{self.prefix}:llm:{scope}:{identity.session_hash}:{identity.ip_hash}"
def _budget_key(self, scope: str, scope_hash: str, current_day: str) -> str:
return f"{self.prefix}:budget:{scope}:{scope_hash}:{current_day}"
def _lock_key(self, scope: str, scope_hash: str) -> str:
return f"{self.prefix}:lock:{scope}:{scope_hash}"
def _circuit_key(self, scope: str) -> str:
return f"{self.prefix}:circuit:{scope}"
def _failure_key(self, model: str) -> str:
return f"{self.prefix}:failure:{model}"
async def check_api(self, identity: RiskIdentity, *, scope: str = "default") -> RiskDecision:
key = self._api_key(identity, scope)
count = await self.backend.incr_window(key, self.config.api_window_seconds)
if count > self.config.api_hard_limit_per_window:
return RiskDecision(
allowed=False,
status_code=429,
reason="请求过于频繁,请稍后再试",
error_code="api_rate_limited",
retry_after_seconds=self.config.api_window_seconds,
)
if count > self.config.api_soft_limit_per_window:
overflow = count - self.config.api_soft_limit_per_window
delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms)
return RiskDecision(allowed=True, delay_ms=delay_ms)
return RiskDecision(allowed=True)
async def check_llm(
self,
identity: RiskIdentity,
*,
scope: str,
estimated_cost: float,
) -> RiskDecision:
global_circuit = await self.backend.get_int(self._circuit_key("global"))
model_circuit = await self.backend.get_int(self._circuit_key(scope))
if global_circuit > 0 or model_circuit > 0:
return RiskDecision(
allowed=False,
status_code=503,
reason="当前推理服务繁忙,请稍后再试",
error_code="llm_circuit_open",
retry_after_seconds=self.config.model_circuit_ttl_seconds,
)
if estimated_cost > self.config.single_request_max_cost_usd:
return RiskDecision(
allowed=False,
status_code=429,
reason="单次请求成本过高,已被拒绝",
error_code="llm_cost_too_high",
)
day = _utc_day()
session_budget = await self.backend.get_float(self._budget_key("session", identity.session_hash, day))
ip_budget = await self.backend.get_float(self._budget_key("ip", identity.ip_hash, day))
global_budget = await self.backend.get_float(self._budget_key("global", "global", day))
if session_budget + estimated_cost > self.config.daily_budget_session_usd:
return RiskDecision(False, 429, "当前匿名会话今日额度已用尽", "session_budget_exhausted", 3600)
if ip_budget + estimated_cost > self.config.daily_budget_ip_usd:
return RiskDecision(False, 429, "当前网络环境今日额度已用尽", "ip_budget_exhausted", 3600)
if global_budget + estimated_cost > self.config.daily_budget_global_usd:
await self.backend.set_int(self._circuit_key("global"), 1, self.config.model_circuit_ttl_seconds)
return RiskDecision(False, 503, "今日全局推理预算已耗尽", "global_budget_exhausted", 3600)
key = self._llm_key(identity, scope)
count = await self.backend.incr_window(key, self.config.llm_window_seconds)
if count > self.config.llm_hard_limit_per_window:
return RiskDecision(False, 429, "推理请求过于频繁,请稍后重试", "llm_rate_limited", self.config.llm_window_seconds)
if count > self.config.llm_soft_limit_per_window:
overflow = count - self.config.llm_soft_limit_per_window
delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms)
return RiskDecision(True, delay_ms=delay_ms)
return RiskDecision(True)
async def reserve_budget(self, identity: RiskIdentity, estimated_cost: float) -> None:
day = _utc_day()
ttl_seconds = 60 * 60 * 24
await self.backend.add_float(self._budget_key("session", identity.session_hash, day), estimated_cost, ttl_seconds)
await self.backend.add_float(self._budget_key("ip", identity.ip_hash, day), estimated_cost, ttl_seconds)
await self.backend.add_float(self._budget_key("global", "global", day), estimated_cost, ttl_seconds)
async def acquire_execution_slot(self, identity: RiskIdentity, *, model: str) -> list[str]:
ttl_seconds = 60 * 15
keys = [
self._lock_key("session", f"{identity.session_hash}:{identity.request_id}"),
self._lock_key("global", identity.request_id),
]
session_running = await self.backend.get_int(self._lock_key("session-count", identity.session_hash))
global_running = await self.backend.get_int(self._lock_key("global-count", "global"))
if session_running >= self.config.session_concurrency_limit:
raise RiskRejected(
RiskDecision(False, 429, "当前会话并发推理过多,请稍后重试", "session_concurrency_limited", 30)
)
if global_running >= self.config.global_concurrency_limit:
raise RiskRejected(
RiskDecision(False, 503, "当前全局推理负载过高,请稍后重试", "global_concurrency_limited", 30)
)
acquired: list[str] = []
for key in keys:
ok = await self.backend.acquire_lock(key, ttl_seconds)
if not ok:
for acquired_key in acquired:
await self.backend.release_lock(acquired_key)
raise RiskRejected(
RiskDecision(False, 429, "当前请求正在执行,请勿重复提交", "duplicate_request", 10)
)
acquired.append(key)
await self.backend.add_float(self._lock_key("session-count", identity.session_hash), 1.0, ttl_seconds)
await self.backend.add_float(self._lock_key("global-count", "global"), 1.0, ttl_seconds)
return acquired
async def release_execution_slot(self, identity: RiskIdentity, lock_keys: list[str], *, model: str) -> None:
for key in lock_keys:
await self.backend.release_lock(key)
session_count_key = self._lock_key("session-count", identity.session_hash)
global_count_key = self._lock_key("global-count", "global")
session_count = max(0.0, await self.backend.get_float(session_count_key) - 1.0)
global_count = max(0.0, await self.backend.get_float(global_count_key) - 1.0)
await self.backend.set_float(session_count_key, session_count, 60 * 15)
await self.backend.set_float(global_count_key, global_count, 60 * 15)
async def record_model_result(self, *, model: str, success: bool) -> None:
if success:
await self.backend.set_int(self._failure_key(model), 0, self.config.model_circuit_ttl_seconds)
return
failures = await self.backend.incr_window(self._failure_key(model), self.config.model_circuit_ttl_seconds)
if failures >= self.config.model_circuit_breaker_failures:
await self.backend.set_int(self._circuit_key(model), 1, self.config.model_circuit_ttl_seconds)
_risk_controller: RiskController | None = None
def get_risk_controller(config: RiskConfig) -> RiskController:
global _risk_controller
if _risk_controller is None:
_risk_controller = RiskController(config)
return _risk_controller
def reset_risk_controller() -> None:
global _risk_controller
_risk_controller = None
+192
View File
@@ -0,0 +1,192 @@
import hashlib
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Any
try:
import psycopg
from psycopg.rows import dict_row
except Exception: # pragma: no cover
psycopg = None
dict_row = None
def _now_ms() -> int:
return int(time.time() * 1000)
def hash_value(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def new_session_id() -> str:
return secrets.token_urlsafe(32)
@dataclass
class SessionRecord:
session_id: str
session_hash: str
created_at_ms: int
last_seen_at_ms: int
first_ip_hash: str
last_ip_hash: str
user_agent_hash: str
risk_score: int = 0
blocked_until_ms: int = 0
is_new: bool = False
class BaseSessionStore:
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
raise NotImplementedError
class InMemorySessionStore(BaseSessionStore):
def __init__(self) -> None:
self.sessions: dict[str, SessionRecord] = {}
self.lock = threading.Lock()
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
now = _now_ms()
with self.lock:
if session_id:
session_hash = hash_value(session_id)
record = self.sessions.get(session_hash)
if record is not None:
record.last_seen_at_ms = now
record.last_ip_hash = client_ip_hash
record.user_agent_hash = user_agent_hash
record.is_new = False
return record
next_session_id = new_session_id()
next_hash = hash_value(next_session_id)
record = SessionRecord(
session_id=next_session_id,
session_hash=next_hash,
created_at_ms=now,
last_seen_at_ms=now,
first_ip_hash=client_ip_hash,
last_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
is_new=True,
)
self.sessions[next_hash] = record
return record
class PostgresSessionStore(BaseSessionStore):
def __init__(self, database_url: str) -> None:
if psycopg is None or dict_row is None:
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL session 存储")
self.database_url = database_url
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
return psycopg.connect(self.database_url, autocommit=True, row_factory=dict_row)
def _ensure_initialized(self) -> None:
if self._initialized:
return
with self._init_lock:
if self._initialized:
return
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS anonymous_sessions (
session_hash TEXT PRIMARY KEY,
created_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
first_ip_hash TEXT NOT NULL,
last_ip_hash TEXT NOT NULL,
user_agent_hash TEXT NOT NULL,
risk_score INTEGER NOT NULL DEFAULT 0,
blocked_until_ms BIGINT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS anonymous_sessions_last_seen_idx ON anonymous_sessions(last_seen_at_ms)"
)
self._initialized = True
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
self._ensure_initialized()
now = _now_ms()
if session_id:
session_hash = hash_value(session_id)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE anonymous_sessions
SET last_seen_at_ms = %s,
last_ip_hash = %s,
user_agent_hash = %s
WHERE session_hash = %s
RETURNING session_hash, created_at_ms, last_seen_at_ms, first_ip_hash, last_ip_hash, user_agent_hash, risk_score, blocked_until_ms
""",
(now, client_ip_hash, user_agent_hash, session_hash),
)
row = cur.fetchone()
if row is not None:
return SessionRecord(
session_id=session_id,
session_hash=row["session_hash"],
created_at_ms=int(row["created_at_ms"]),
last_seen_at_ms=int(row["last_seen_at_ms"]),
first_ip_hash=row["first_ip_hash"],
last_ip_hash=row["last_ip_hash"],
user_agent_hash=row["user_agent_hash"],
risk_score=int(row["risk_score"] or 0),
blocked_until_ms=int(row["blocked_until_ms"] or 0),
is_new=False,
)
next_session_id = new_session_id()
next_hash = hash_value(next_session_id)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO anonymous_sessions (
session_hash, created_at_ms, last_seen_at_ms, first_ip_hash, last_ip_hash, user_agent_hash
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(next_hash, now, now, client_ip_hash, client_ip_hash, user_agent_hash),
)
return SessionRecord(
session_id=next_session_id,
session_hash=next_hash,
created_at_ms=now,
last_seen_at_ms=now,
first_ip_hash=client_ip_hash,
last_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
is_new=True,
)
_session_store: BaseSessionStore | None = None
def get_session_store(database_url: str | None = None) -> BaseSessionStore:
global _session_store
if _session_store is not None:
return _session_store
if database_url:
_session_store = PostgresSessionStore(database_url)
else:
_session_store = InMemorySessionStore()
return _session_store
def reset_session_store() -> None:
global _session_store
_session_store = None
+79
View File
@@ -0,0 +1,79 @@
import importlib
import os
import sys
from pathlib import Path
from fastapi.testclient import TestClient
os.environ["JOB_BACKEND"] = "memory"
CURRENT_DIR = Path(__file__).resolve().parent
BACKEND_DIR = CURRENT_DIR.parent
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import job_handlers # type: ignore
import job_system # type: ignore
main = importlib.import_module("main")
API_KEY = main.API_KEY
HEADERS = {"X-API-Key": API_KEY}
def setup_function():
job_system.reset_job_manager()
main._handlers_registered = False
def _submit(client, content="test document", doc_type="txt", headers=None):
return client.post("/v1/compress/submit", headers=headers if headers is not None else HEADERS, json={
"content": content,
"docType": doc_type,
})
def _status(client, task_id, headers=None):
return client.get(f"/v1/compress/status?task_id={task_id}", headers=headers if headers is not None else HEADERS)
def test_submit_empty_content_returns_400():
with TestClient(main.app) as client:
resp = _submit(client, "")
assert resp.status_code == 400
def test_submit_too_long_returns_400(monkeypatch):
monkeypatch.setattr(main, "DOC_COMPRESS_CONTEXT_LIMIT", 10)
with TestClient(main.app) as client:
resp = _submit(client, "a" * 100)
assert resp.status_code == 400
def test_submit_success_returns_task_id():
with TestClient(main.app) as client:
resp = _submit(client, "hello world")
assert resp.status_code == 200
data = resp.json()
assert "task_id" in data
assert data["status"] == "queued"
def test_status_not_found_returns_404():
with TestClient(main.app) as client:
resp = _status(client, "nonexistent-id")
assert resp.status_code == 404
def test_status_completed(monkeypatch):
async def fake_call_ollama(prompt, system_prompt=None, **kwargs): # noqa: ARG001
return {"content": f"[compressed] {prompt[:20]}"}
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
with TestClient(main.app) as client:
resp = _submit(client, "important document text")
task_id = resp.json()["task_id"]
status_resp = _status(client, task_id)
data = status_resp.json()
assert status_resp.status_code == 200
assert data["status"] in {"queued", "processing", "completed"}
+23 -16
View File
@@ -128,9 +128,9 @@ def test_build_chat_stream_payload_with_thinking():
def test_call_ollama_non_streaming(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["url"] = url
captured["json"] = json
async def fake_post(*args, **kwargs):
captured["url"] = args[1] if len(args) > 1 else kwargs.get("url", "")
captured["json"] = kwargs.get("json")
class FakeResp:
def raise_for_status(self): pass
@@ -138,7 +138,7 @@ def test_call_ollama_non_streaming(monkeypatch):
return FakeResp()
async def fake_client(*args, **kwargs):
def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
@@ -168,7 +168,8 @@ def test_stream_ollama_text_deltas(monkeypatch):
])
class LineIterator:
async def __anext__(self):
def __aiter__(self2): return self2
async def __anext__(self2):
try:
return next(lines_iter)
except StopIteration:
@@ -177,8 +178,8 @@ def test_stream_ollama_text_deltas(monkeypatch):
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
def raise_for_status(self2): pass
def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
@@ -186,10 +187,12 @@ def test_stream_ollama_text_deltas(monkeypatch):
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
return Client()
async def fake_client(*args, **kwargs):
def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
@@ -217,7 +220,8 @@ def test_stream_ollama_events_thinking_and_content(monkeypatch):
])
class LineIterator:
async def __anext__(self):
def __aiter__(self2): return self2
async def __anext__(self2):
try:
return next(lines_iter)
except StopIteration:
@@ -226,8 +230,8 @@ def test_stream_ollama_events_thinking_and_content(monkeypatch):
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
def raise_for_status(self2): pass
def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
@@ -235,10 +239,12 @@ def test_stream_ollama_events_thinking_and_content(monkeypatch):
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
return Client()
async def fake_client(*args, **kwargs):
def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
@@ -260,9 +266,9 @@ def test_stream_ollama_events_thinking_and_content(monkeypatch):
def test_call_vlm_ocr(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["url"] = url
captured["json"] = json
async def fake_post(*args, **kwargs):
captured["url"] = args[1] if len(args) > 1 else kwargs.get("url", "")
captured["json"] = kwargs.get("json")
class FakeResp:
def raise_for_status(self): pass
@@ -270,7 +276,7 @@ def test_call_vlm_ocr(monkeypatch):
return FakeResp()
async def fake_client(*args, **kwargs):
def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
@@ -293,3 +299,4 @@ def test_call_vlm_ocr(monkeypatch):
image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1
assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert captured["json"]["options"]["think"] is False
-225
View File
@@ -1,225 +0,0 @@
import asyncio
import importlib
import sys
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
try:
llm = importlib.import_module("llm")
except ModuleNotFoundError:
pytest.skip("llm module dependencies are not available", allow_module_level=True)
def test_extract_message_with_content_and_thinking():
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
content, thinking = llm._extract_message(resp)
assert content == "hello world"
assert thinking == "reasoning"
def test_extract_message_empty_content():
resp = {"choices": [{"message": {"content": "", "thinking": None}}]}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_dict_no_choices():
resp = {"not_choices": []}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_empty_dict():
resp = {}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_delta_text_from_chunk():
chunk = {"choices": [{"delta": {"content": "text"}}]}
assert llm._extract_delta_text(chunk) == "text"
def test_extract_delta_thinking_from_chunk():
chunk = {"choices": [{"delta": {"thinking": "thought"}}]}
assert llm._extract_delta_thinking(chunk) == "thought"
def test_call_ollama_no_system(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt=None, tag="no-system")
)
assert result["content"] == "ok"
# Should only have user message, no system
assert len(captured["json"]["messages"]) == 1
def test_call_ollama_with_system(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt="sys prompt", tag="with-system")
)
assert result["content"] == "ok"
# Should have both system and user messages
msgs = captured["json"]["messages"]
assert len(msgs) == 2
assert msgs[0]["role"] == "system"
def test_call_ollama_with_custom_model(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("prompt", model="custom-model")
)
assert captured["json"]["model"] == "custom-model"
def test_stream_ollama_events_error_handling(monkeypatch):
def make_lines():
lines_iter = iter([
'data: {"error": "model not found"}',
])
class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
async def collect():
try:
async for _ in llm.stream_ollama_events("prompt", tag="err"):
pass
except RuntimeError as e:
return str(e)
result = asyncio.run(collect())
assert "model not found" in str(result)
def test_call_vlm_ocr_payload_format(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ocr result"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(llm.call_vlm_ocr(b"image"))
assert result == "ocr result"
# Verify vision format: image_url content part with base64
msgs = captured["json"]["messages"]
assert len(msgs) == 1
content_parts = msgs[0]["content"]
image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1
+25 -45
View File
@@ -1,26 +1,37 @@
import asyncio
import importlib
import os
import sys
import threading
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
os.environ["JOB_BACKEND"] = "memory"
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
try:
main = importlib.import_module("main")
except ModuleNotFoundError:
pytest.skip("main module dependencies are not available", allow_module_level=True)
import job_handlers # type: ignore
import job_system # type: ignore
import risk_control # type: ignore
import session_store # type: ignore
import audit_store # type: ignore
main = importlib.import_module("main")
API_KEY_HEADERS = {"X-API-Key": "your-secret-key-here"}
def setup_function():
job_system.reset_job_manager()
risk_control.reset_risk_controller()
session_store.reset_session_store()
audit_store.reset_audit_store()
main._handlers_registered = False
def _completion_payload():
return {
"prefix": "hello",
@@ -32,7 +43,6 @@ def _completion_payload():
def test_cancel_endpoint_cancels_running_task(monkeypatch):
main.ACTIVE_COMPLETIONS.clear()
started = threading.Event()
cancelled = threading.Event()
@@ -45,21 +55,21 @@ def test_cancel_endpoint_cancels_running_task(monkeypatch):
cancelled.set()
raise
monkeypatch.setattr(main, "call_ollama", fake_call_ollama)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("system", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("prefix", "suffix"))
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
request_id = "req-cancel-1"
with TestClient(main.app) as client:
request_id = "req-cancel-1"
completion_headers = {**API_KEY_HEADERS, "X-Request-Id": request_id}
response_box = {}
def send_completion():
response_box["response"] = client.post(
with client.stream(
"POST",
"/v1/completions",
headers=completion_headers,
headers={**API_KEY_HEADERS, "X-Request-Id": request_id},
json=_completion_payload(),
)
) as response:
response_box["status_code"] = response.status_code
response_box["body"] = "".join(response.iter_text())
completion_thread = threading.Thread(target=send_completion, daemon=True)
completion_thread.start()
@@ -77,16 +87,10 @@ def test_cancel_endpoint_cancels_running_task(monkeypatch):
completion_thread.join(timeout=5.0)
assert not completion_thread.is_alive()
assert cancelled.wait(timeout=2.0)
completion_response = response_box["response"]
# 499 = client disconnected (TestClient timeout during cancel)
assert completion_response.status_code in (200, 499)
if completion_response.status_code == 200:
assert completion_response.json()["cancelled"] is True
assert "event: cancelled" in response_box["body"]
def test_cancel_not_found():
main.ACTIVE_COMPLETIONS.clear()
with TestClient(main.app) as client:
response = client.post(
"/v1/completions/cancel",
@@ -95,27 +99,3 @@ def test_cancel_not_found():
)
assert response.status_code == 200
assert response.json() == {"cancelled": False, "status": "not_found"}
def test_completion_normal_flow(monkeypatch):
main.ACTIVE_COMPLETIONS.clear()
async def fake_call_ollama(*args, **kwargs):
return {"content": "completion text", "think": ""}
monkeypatch.setattr(main, "call_ollama", fake_call_ollama)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("system", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("prefix", "suffix"))
with TestClient(main.app) as client:
response = client.post(
"/v1/completions",
headers=API_KEY_HEADERS,
json=_completion_payload(),
)
assert response.status_code == 200
data = response.json()
assert data["content"] == "completion text"
assert data["request_id"] is not None
assert main.ACTIVE_COMPLETIONS == {}
+180 -211
View File
@@ -1,35 +1,40 @@
import base64
import asyncio
import importlib
import os
import sys
import base64
import types
import pytest
from unittest.mock import MagicMock
from pathlib import Path
from types import SimpleNamespace
from fastapi.testclient import TestClient
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
os.environ["JOB_BACKEND"] = "memory"
os.environ["DOCS_BACKEND"] = "memory"
if "tts_asr" not in sys.modules:
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules["tts_asr"] = fake_tts_asr
CURRENT_DIR = Path(__file__).resolve().parent
BACKEND_DIR = CURRENT_DIR.parent
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import main # type: ignore
import pro_completions # type: ignore
import job_handlers # type: ignore
import job_system # type: ignore
import docs_store # type: ignore
import risk_control # type: ignore
import session_store # type: ignore
import audit_store # type: ignore
API_KEY = main.API_KEY
HEADERS = {"X-API-Key": API_KEY}
main = importlib.import_module("main")
HEADERS = {"X-API-Key": main.API_KEY}
@pytest.fixture(autouse=True)
def _clear_active_completions():
main.ACTIVE_COMPLETIONS.clear()
pro_completions.PRO_STATES.clear()
yield
main.ACTIVE_COMPLETIONS.clear()
pro_completions.PRO_STATES.clear()
def setup_function():
job_system.reset_job_manager()
docs_store.reset_document_store()
risk_control.reset_risk_controller()
session_store.reset_session_store()
audit_store.reset_audit_store()
main._handlers_registered = False
class DummyRequest:
@@ -51,64 +56,12 @@ def test_preview_long_text_truncated():
assert main._preview(long_text) == long_text[:80] + "..."
def test_preview_none_input():
assert main._preview(None) == ""
def test_preview_newlines_replaced():
assert main._preview("line1\nline2") == "line1\\nline2"
def test_sanitize_markdown_strips_image_markdown():
assert "![alt](image.png)" not in main._sanitize_converted_markdown(
"text with image ![alt](image.png) end"
)
def test_sanitize_markdown_strips_img_tag():
assert "<img" not in main._sanitize_converted_markdown("<img src='x.png'/>")
def test_sanitize_markdown_collapse_newlines():
assert main._sanitize_converted_markdown("a\n\n\nb\n\n\n\nc") == "a\n\nb\n\nc"
def test_sanitize_markdown_normalize_crlf():
result = main._sanitize_converted_markdown("line1\r\nline2\r\n")
assert "line1\nline2" in result
assert "\r" not in result
assert "![alt](image.png)" not in main._sanitize_converted_markdown("text ![alt](image.png)")
def test_sanitize_inline_completion_strips_prefill():
assert main.sanitize_inline_completion_content(
"系统非常适合写作",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_fim_middle():
assert main.sanitize_inline_completion_content(
"<|fim_middle|>系统非常适合写作<|end|>",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_polluted_chat_output():
polluted = (
"on new line? Prefix ends with newline already. The suffix starts with no newline. "
"We need to consider if output should end with newline? The suffix starts with no newline. "
"So we output: \"让我们一起探索 AI 的无限可能。\""
"<|end|><|start|>assistant<|channel|>final|fim_middle|>系统让我们一起探索 AI 的无限可能。"
)
assert main.sanitize_inline_completion_content(
polluted,
prefill="系统",
) == "让我们一起探索 AI 的无限可能。"
def test_get_client_ip_from_host():
req = DummyRequest(host="1.2.3.4", headers={})
assert main.get_client_ip(req) == "1.2.3.4"
assert main.sanitize_inline_completion_content("系统非常适合写作", prefill="系统") == "非常适合写作"
def test_get_client_ip_header_overrides_host():
@@ -116,133 +69,139 @@ def test_get_client_ip_header_overrides_host():
assert main.get_client_ip(req) == "5.6.7.8"
def test_get_client_ip_when_client_missing():
req = DummyRequest(host=None, headers={"X-Client-IP": "9.9.9.9"})
req.client = None
assert main.get_client_ip(req) == "9.9.9.9"
def test_post_completions_wrong_api_key_returns_401():
client = TestClient(main.app)
resp = client.post("/v1/completions", json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
})
assert resp.status_code == 401
def test_post_completions_privacy_mode(monkeypatch):
captured = {}
def test_post_completions_without_api_key_uses_anonymous_session(monkeypatch):
async def fake_call(*args, **kwargs):
captured["kwargs"] = kwargs
return {"content": "done", "think": ""}
monkeypatch.setattr(main, "call_ollama", fake_call)
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
return {"content": "系统done", "think": ""}
client = TestClient(main.app)
resp = client.post("/v1/completions", headers=HEADERS, json={
monkeypatch.setattr(job_handlers, "call_ollama", fake_call)
with TestClient(main.app) as client:
with client.stream("POST", "/v1/completions", json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
})
}) as resp:
assert resp.status_code == 200
data = resp.json()
assert data.get("content") == "done"
# enable_thinking removed in OpenAI-compatible rewrite
assert captured["kwargs"]["thinking"] == "low"
assert main.config.session_cookie_name in resp.cookies
def test_old_post_pro_stream_returns_404():
client = TestClient(main.app)
resp = client.post("/v1/pro/completions/stream", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"model_thinking": "high",
"privacy_mode": True,
})
assert resp.status_code == 404
def test_post_completions_invalid_api_key_returns_403():
with TestClient(main.app) as client:
resp = client.post(
"/v1/completions",
headers={"X-API-Key": "invalid-key"},
json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
},
)
assert resp.status_code == 403
def test_post_pro_completion_returns_sse_and_status(monkeypatch):
captured = {}
def test_post_completions_returns_sse_done(monkeypatch):
async def fake_call(*args, **kwargs):
return {"content": "系统done", "think": ""}
async def fake_stream_events(*args, **kwargs):
captured["kwargs"] = kwargs
yield "thinking", ""
yield "content", "深度"
yield "content", "回答"
monkeypatch.setattr(job_handlers, "call_ollama", fake_call)
with TestClient(main.app) as client:
with client.stream("POST", "/v1/completions", headers=HEADERS, json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: queued" in body
assert "event: started" in body
assert "event: result" in body
assert "event: done" in body
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
client = TestClient(main.app)
def test_stream_job_emits_keepalive_during_idle(monkeypatch):
async def fake_queue_job(*_args, **_kwargs):
return "job-keepalive"
class FakeManager:
def register_handler(self, *_args, **_kwargs):
return None
async def stream_events(self, job_id):
yield {"event": "queued", "job_id": job_id}
yield {"event": "started", "job_id": job_id}
await asyncio.sleep(0.03)
yield {"event": "done", "job_id": job_id, "result": {"content": "ok"}}
monkeypatch.setattr(main, "STREAM_HEARTBEAT_SECONDS", 0.01)
monkeypatch.setattr(main, "_queue_job", fake_queue_job)
monkeypatch.setattr(main, "get_job_manager", lambda: FakeManager())
with TestClient(main.app) as client:
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"instruction": "expand",
"pro_thinking": "high",
"privacy_mode": True,
"prefix": "hello", "suffix": "", "languageId": "markdown",
"instruction": "expand", "pro_thinking": "medium", "privacy_mode": True,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: queued" in body
assert "event: started" in body
assert "event: thinking" in body
assert "event: chunk" in body
assert ": keepalive" in body
assert "event: done" in body
assert "深度" in body
assert "回答" in body
assert captured["kwargs"]["use_pro_model"] is True
assert captured["kwargs"]["thinking"] == "high"
request_id = next(iter(pro_completions.PRO_STATES))
status_resp = client.get(f"/v1/pro/completions/status/{request_id}", headers=HEADERS)
assert status_resp.status_code == 200
assert status_resp.json()["status"] == "done"
assert main.ACTIVE_COMPLETIONS == {}
def test_post_ocr_mocked(monkeypatch):
async def fake_ocr(*args, **kwargs):
return "OCR result text"
monkeypatch.setattr(main, "call_vlm_ocr", fake_ocr)
client = TestClient(main.app)
monkeypatch.setattr(job_handlers, "call_vlm_ocr", fake_ocr)
img_b64 = base64.b64encode(b"pretend image data").decode()
resp = client.post("/v1/ocr", headers=HEADERS, json={
with TestClient(main.app) as client:
with client.stream("POST", "/v1/ocr", headers=HEADERS, json={
"image": img_b64, "filename": "test.jpg", "language": "auto",
})
}) as resp:
assert resp.status_code == 200
j = resp.json()
assert j["text"] == "OCR result text"
assert j["filename"] == "test.jpg"
body = "".join(resp.iter_text())
assert "OCR result text" in body
def test_post_ocr_invalid_base64_returns_500():
client = TestClient(main.app)
resp = client.post("/v1/ocr", headers=HEADERS, json={
"image": "not-base64!!!", "filename": "test.jpg",
})
assert resp.status_code == 500
def test_post_video_ocr_merges_ocr_and_asr(monkeypatch):
async def fake_ocr(*args, **kwargs):
return "画面文字"
async def fake_asr(*args, **kwargs):
return SimpleNamespace(text="音频转写")
monkeypatch.setattr(job_handlers, "call_vlm_ocr", fake_ocr)
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
monkeypatch.setattr(job_handlers, "extract_audio_wav_bytes", lambda _path: b"fake wav")
video_b64 = base64.b64encode(b"pretend video data").decode()
with TestClient(main.app) as client:
with client.stream("POST", "/v1/ocr", headers=HEADERS, json={
"image": video_b64,
"filename": "sample.mp4",
"language": "auto",
"media_type": "video",
"mime_type": "video/mp4",
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "视频画面 OCR" in body
assert "视频音频 ASR" in body
assert "画面文字" in body
assert "音频转写" in body
def test_post_convert_txt_returns_markdown():
client = TestClient(main.app)
content = base64.b64encode(b"hello world").decode()
resp = client.post("/v1/convert", headers=HEADERS, json={
with TestClient(main.app) as client:
with client.stream("POST", "/v1/convert", headers=HEADERS, json={
"file": content, "filename": "sample.txt",
})
}) as resp:
assert resp.status_code == 200
j = resp.json()
assert j["markdown"] == "hello world"
assert j["filename"] == "sample.txt"
body = "".join(resp.iter_text())
assert "hello world" in body
def test_post_convert_unsupported_extension_returns_500():
client = TestClient(main.app)
content = base64.b64encode(b"data").decode()
with TestClient(main.app) as client:
resp = client.post("/v1/convert", headers=HEADERS, json={
"file": content, "filename": "sample.xlsx",
})
@@ -250,57 +209,67 @@ def test_post_convert_unsupported_extension_returns_500():
assert "仅支持" in resp.json()["error"]
def test_post_convert_docx_with_mocked_markitdown(monkeypatch):
class FakeResult:
text_content = "markdown from docx"
class FakeMD:
def convert(self, path):
return FakeResult()
monkeypatch.setattr(main, "_get_markitdown", lambda: FakeMD())
client = TestClient(main.app)
content = base64.b64encode(b"docx content").decode()
resp = client.post("/v1/convert", headers=HEADERS, json={
"file": content, "filename": "sample.docx",
def test_docs_nodes_crud_round_trip():
with TestClient(main.app) as client:
folder_resp = client.post("/v1/docs/folders", headers=HEADERS, json={
"name": "项目资料",
"parentId": None,
})
assert resp.status_code == 200
j = resp.json()
assert j["markdown"] == "markdown from docx"
assert folder_resp.status_code == 200
folder = folder_resp.json()["node"]
def test_post_cancel_non_existent_returns_not_found():
client = TestClient(main.app)
resp = client.post("/v1/completions/cancel", headers=HEADERS, json={
"request_id": "non-existent", "reason": "abort",
file_resp = client.post("/v1/docs/files/text", headers=HEADERS, json={
"name": "notes.md",
"parentId": folder["id"],
"content": "# hello",
})
assert resp.status_code == 200
data = resp.json()
assert data["cancelled"] is False
assert data["status"] == "not_found"
assert file_resp.status_code == 200
file_node = file_resp.json()["node"]
assert file_node["previewText"] == "# hello"
list_resp = client.get("/v1/docs/nodes", headers=HEADERS)
assert list_resp.status_code == 200
nodes = list_resp.json()["nodes"]
assert len(nodes) == 2
def test_post_cancel_wrong_api_key_returns_401():
client = TestClient(main.app)
resp = client.post("/v1/completions/cancel", json={
"request_id": "id", "reason": "abort",
rename_resp = client.patch(f"/v1/docs/nodes/{file_node['id']}", headers=HEADERS, json={
"name": "renamed.md",
})
assert resp.status_code == 401
assert rename_resp.status_code == 200
assert rename_resp.json()["node"]["name"] == "renamed.md"
blob_resp = client.get(f"/v1/docs/files/{file_node['id']}/blob", headers=HEADERS)
assert blob_resp.status_code == 200
assert blob_resp.content == b"# hello"
delete_resp = client.delete(f"/v1/docs/nodes/{folder['id']}", headers=HEADERS)
assert delete_resp.status_code == 200
final_list = client.get("/v1/docs/nodes", headers=HEADERS)
assert final_list.status_code == 200
assert final_list.json()["nodes"] == []
def test_post_cancel_already_done(monkeypatch):
main.ACTIVE_COMPLETIONS.clear()
# Create a mock task that appears done
mock_task = MagicMock()
mock_task.done.return_value = True
mock_task.cancel = MagicMock()
main.ACTIVE_COMPLETIONS["done-id"] = mock_task
def test_docs_file_upload_and_blob_replace():
with TestClient(main.app) as client:
upload_resp = client.post(
"/v1/docs/files/upload",
headers=HEADERS,
files={"file": ("image.png", b"png-bytes", "image/png")},
data={"parent_id": ""},
)
assert upload_resp.status_code == 200
node = upload_resp.json()["node"]
assert node["storageKind"] == "blob"
client = TestClient(main.app)
resp = client.post("/v1/completions/cancel", headers=HEADERS, json={
"request_id": "done-id", "reason": "abort",
})
assert resp.status_code == 200
data = resp.json()
assert data["cancelled"] is False
assert data["status"] == "already_done"
main.ACTIVE_COMPLETIONS.clear()
replace_resp = client.put(
f"/v1/docs/files/{node['id']}/blob",
headers=HEADERS,
files={"file": ("photo.jpg", b"jpeg-bytes", "image/jpeg")},
)
assert replace_resp.status_code == 200
assert replace_resp.json()["node"]["name"] == "photo.jpg"
blob_resp = client.get(f"/v1/docs/files/{node['id']}/blob", headers=HEADERS)
assert blob_resp.status_code == 200
assert blob_resp.content == b"jpeg-bytes"
+67 -71
View File
@@ -1,28 +1,30 @@
import importlib
import os
import sys
import types
import asyncio
import threading
from pathlib import Path
from fastapi.testclient import TestClient
os.environ["JOB_BACKEND"] = "memory"
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
if "tts_asr" not in sys.modules:
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules["tts_asr"] = fake_tts_asr
import main # type: ignore
import pro_completions # type: ignore
import job_handlers # type: ignore
import job_system # type: ignore
import prompt # type: ignore
main = importlib.import_module("main")
HEADERS = {"X-API-Key": main.API_KEY}
def setup_function():
job_system.reset_job_manager()
main._handlers_registered = False
def _payload():
return {
"prefix": "Before",
@@ -31,84 +33,78 @@ def _payload():
"instruction": "expand",
"pro_thinking": "medium",
"privacy_mode": True,
"user_preferences": {
"language": "zh",
"country": "CN",
"timezone": "Asia/Shanghai",
},
}
def setup_function():
pro_completions.PRO_STATES.clear()
def teardown_function():
pro_completions.PRO_STATES.clear()
def test_pro_queue_full_returns_429(monkeypatch):
monkeypatch.setattr(pro_completions, "PRO_QUEUE_MAX_SIZE", 0)
client = TestClient(main.app)
async def fake_queue_job(*args, **kwargs):
raise job_system.QueueFullError("pro_completion", 8)
monkeypatch.setattr(main, "_queue_job", fake_queue_job)
with TestClient(main.app) as client:
response = client.post("/v1/pro/completions", headers=HEADERS, json=_payload())
assert response.status_code == 429
assert response.json()["error"] == "PRO queue is full"
def test_pro_status_missing_returns_404():
client = TestClient(main.app)
with TestClient(main.app) as client:
response = client.get("/v1/pro/completions/status/missing", headers=HEADERS)
assert response.status_code == 404
def test_pro_prompt_uses_simple_chat_instruction():
system_prompt, user_prompt = pro_completions._build_pro_prompts(
def test_pro_prompt_uses_pro_specific_instruction():
system_prompt, user_prompt = prompt.build_pro_completion_prompts(
prefix="欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统",
suffix="",
language_id="markdown",
instruction="",
pro_thinking_level="high",
)
combined = f"{system_prompt}\n{user_prompt}".lower()
assert "pro block" not in combined
assert "replacement" not in combined
assert "final answer" not in combined
assert "markdown before cursor" in combined
assert "markdown after cursor" in combined
assert "continue the markdown naturally" in combined
assert "[pro] model for llm-in-text" in combined
assert "pro_mode: true" in combined
assert "pro_thinking_level: high" in combined
def test_pro_cancel_waits_for_stream_cleanup(monkeypatch):
started = threading.Event()
cleaned = threading.Event()
async def fake_stream_events(*args, **kwargs):
started.set()
try:
yield "thinking", ""
while True:
await asyncio.sleep(0.05)
finally:
cleaned.set()
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
request_id = "pro-cancel-cleanup"
headers = {**HEADERS, "X-Request-Id": request_id}
response_box = {}
with TestClient(main.app) as client:
def send_stream():
with client.stream("POST", "/v1/pro/completions", headers=headers, json=_payload()) as response:
response_box["status_code"] = response.status_code
response_box["body"] = "".join(response.iter_text())
stream_thread = threading.Thread(target=send_stream, daemon=True)
stream_thread.start()
assert started.wait(timeout=2.0)
cancel_response = client.post(
"/v1/pro/completions/cancel",
headers=HEADERS,
json={"request_id": request_id, "reason": "test"},
def test_pro_prompt_accepts_serialized_preferences():
_, user_prompt = prompt.build_pro_completion_prompts(
prefix="Before",
suffix="After",
language_id="markdown",
instruction="expand",
preferences={
"language": "zh",
"country": "CN",
"timezone": "Asia/Shanghai",
},
)
assert cancel_response.status_code == 200
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
assert cleaned.wait(timeout=2.0)
assert "Preferred language: zh" in user_prompt
assert "Preferred country: CN" in user_prompt
assert "Preferred timezone: Asia/Shanghai" in user_prompt
stream_thread.join(timeout=5.0)
assert not stream_thread.is_alive()
def test_pro_stream_returns_standard_events(monkeypatch):
async def fake_stream_events(*args, **kwargs):
yield "thinking", ""
yield "content", "深度"
yield "content", "回答"
monkeypatch.setattr(job_handlers, "stream_ollama_events", fake_stream_events)
with TestClient(main.app) as client:
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json=_payload()) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: queued" in body
assert "event: started" in body
assert "event: progress" in body
assert "event: result" in body
assert "event: done" in body
assert "深度" in body
assert "回答" in body
-147
View File
@@ -1,147 +0,0 @@
import sys
import re
from pathlib import Path
# Ensure the project root is in sys.path so imports like `from backend import prompt` work
ROOT = Path(__file__).resolve().parents[2]
BACKEND_DIR = ROOT / "backend"
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(BACKEND_DIR))
from backend import prompt # type: ignore
def test_get_current_datetime_auto_format():
s = prompt._get_current_datetime("auto")
assert isinstance(s, str)
# Expect a date-like prefix: YYYY-MM-DD
assert re.match(r"^\d{4}-\d{2}-\d{2}", s)
# Expect a 3-letter weekday somewhere
assert re.search(r"\b[A-Za-z]{3}\b", s)
# Accept either an explicit UTC offset or a UTC label
assert re.search(r"UTC|[+-]\d{2}:?\d{2}", s)
def test_get_current_datetime_utc_plus5():
s = prompt._get_current_datetime("UTC+5")
assert isinstance(s, str)
assert "UTC+5" in s
def test_get_current_datetime_gmt_minus3():
s = prompt._get_current_datetime("GMT-3")
assert isinstance(s, str)
assert "GMT-3" in s
def test_get_current_datetime_new_york_fallback():
s = prompt._get_current_datetime("America/New_York")
assert isinstance(s, str)
# Fallback behavior: allow either an explicit offset or a simple date prefix
ok = bool(re.search(r"[+-]\d{2}:?\d{2}", s)) or bool(re.match(r"^\d{4}-\d{2}-\d{2}", s))
assert ok
def test_sanitize_language_id_empty_none_and_chars():
# Empty / None should map to markdown by design
assert prompt._sanitize_language_id("") == "markdown"
assert prompt._sanitize_language_id(None) == "markdown"
# Dangerous chars should be stripped
sanitized = prompt._sanitize_language_id("<script>alert(1)</script>")
assert "<" not in sanitized and ">" not in sanitized
# Valid input preserved
assert prompt._sanitize_language_id("python") == "python"
# Truncation at 32 chars
long_input = "a" * 50
trimmed = prompt._sanitize_language_id(long_input)
assert len(trimmed) <= 32
assert trimmed == "a" * min(32, len(long_input))
def test_normalize_newlines():
mixed = "line1\r\nline2\rline3\n"
norm = prompt._normalize_newlines(mixed)
assert norm == "line1\nline2\nline3\n"
def test_canonical_language_id_synonyms_and_unknown():
assert prompt._canonical_language_id("md") == "markdown"
assert prompt._canonical_language_id("py") == "python"
assert prompt._canonical_language_id("js") == "javascript"
assert prompt._canonical_language_id("ts") == "typescript"
assert prompt._canonical_language_id("yml") == "yaml"
assert prompt._canonical_language_id("Rust") == "rust"
def test_language_guidance_behaviors():
# markdown yields empty guidance
assert prompt._language_guidance("markdown") == ""
# mermaid guidance should mention mermaid
g_mermaid = prompt._language_guidance("mermaid")
assert isinstance(g_mermaid, str)
assert "mermaid" in g_mermaid.lower()
# python / javascript should reference the language
g_py = prompt._language_guidance("python")
assert isinstance(g_py, str) and "python" in g_py.lower()
g_js = prompt._language_guidance("javascript")
assert isinstance(g_js, str) and "javascript" in g_js.lower()
# unknown language should return a string as fallback
g_unknown = prompt._language_guidance("unknownlang")
assert isinstance(g_unknown, str)
def test_build_inline_system_prompt_templates():
s_md = prompt.build_inline_system_prompt("markdown")
assert isinstance(s_md, str) and "markdown" in s_md.lower()
s_mermaid = prompt.build_inline_system_prompt("mermaid")
assert isinstance(s_mermaid, str) and "mermaid" in s_mermaid.lower()
def test_prepare_context_strips_br_tags():
prefix, suffix = prompt._prepare_context("<br>hello<br/>", "world<br />")
assert "<br" not in prefix
assert "<br" not in suffix
def test_cursor_and_fence_helpers_basic():
sample = "```python\nprint('hi')\n"
assert prompt._cursor_in_fenced_code_block(sample) is True
assert prompt._cursor_in_fenced_code_block("plain text") is False
assert prompt._active_fence_language(sample) == "python"
assert prompt._active_fence_language("plain text") == "none"
def test_is_mermaid_context_detection():
assert prompt._is_mermaid_context("flowchart TD", "", "none") is True
assert prompt._is_mermaid_context("```mermaid\n", "\n```", "mermaid") is True
assert prompt._is_mermaid_context("plain text", "", "none") is False
def test_build_completion_prompts_with_userprefs():
class UserPrefs:
language = "python"
currency = "USD"
timezone = "UTC+0"
system, user, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown",
preferences=UserPrefs(),
)
assert isinstance(system, str)
assert isinstance(user, str)
assert prefill == "hello"
assert "python" in user.lower() or "USD" in user
def test_build_completion_prompts_privacy_mode_location_empty():
system, user, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown",
location="",
)
assert isinstance(system, str)
assert isinstance(user, str)
assert prefill == "hello"
def test_build_prompt_backward_compatibility():
res = prompt.build_prompt(prefix="hello", suffix="world", language_id="markdown")
assert isinstance(res, str)
-193
View File
@@ -1,193 +0,0 @@
import os
import sys
import asyncio
import types
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _make_mlx_stub():
"""Create minimal MLX stub for testing without Apple Silicon"""
mlx = types.SimpleNamespace()
mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
mlx.core.array = mx_array
mlx.nn = types.SimpleNamespace()
return mlx
def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path, **kwargs):
model = MagicMock()
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
mlx_stub = _make_mlx_stub()
sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr
return tts_asr
@pytest.fixture(autouse=True)
def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {}
for k in ['HF_ENDPOINT']:
saved[k] = os.environ.get(k)
if k in os.environ:
del os.environ[k]
yield
for k, v in saved.items():
if v is not None:
os.environ[k] = v # type: ignore (unused var)
class TestRequestResponseModels:
"""Pydantic 数据模型测试"""
def test_tts_request_defaults(self):
tts = _reload_tts_asr_with_mocks()
req = tts.TTSRequest(text="hello")
assert req.text == "hello"
assert req.speaker == "Vivian"
def test_asr_request_defaults(self):
tts = _reload_tts_asr_with_mocks()
req = tts.ASRRequest(audio_base64="dGVzdA==")
assert req.audio_base64 == "dGVzdA=="
assert req.language == "zh-CN"
def test_asr_request_custom_language(self):
tts = _reload_tts_asr_with_mocks()
req = tts.ASRRequest(audio_base64="dGVzdA==", language="en")
assert req.language == "en"
def test_model_status_defaults(self):
tts = _reload_tts_asr_with_mocks()
status = tts.ModelStatus(tts_loaded=False, asr_loaded=True, device="cpu")
assert not status.tts_loaded
assert status.asr_loaded
class TestDeviceDetection:
"""设备检测测试"""
def test_device_map_returns_string(self):
tts = _reload_tts_asr_with_mocks()
device = tts._get_device_map()
assert isinstance(device, str)
class TestModelLoading:
"""模型加载测试"""
def test_load_asr_skips_when_mlx_unavailable(self):
"""mlx_audio 未安装时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
# Don't inject mlx stubs — simulate missing MLX
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
tts_asr._load_asr_models() # should not crash
assert tts_asr._asr_model is None
def test_load_asr_from_path_success(self):
tts = _reload_tts_asr_with_mocks()
# Mock snapshot_download to return a path, mock stt_load to succeed
with patch('backend.tts_asr.snapshot_download', return_value='/fake/path'): # type: ignore
tts._load_asr_from_path('/fake/path')
assert tts._asr_model is not None # type: ignore (MagicMock)
class TestWarmupFunctions:
"""预热函数测试"""
def test_warmup_functions_callable(self):
tts = _reload_tts_asr_with_mocks()
assert callable(tts._warmup_tts) # type: ignore (unused var)
assert callable(tts._warmup_all)
def test_warmup_asr_skips_when_mlx_unavailable(self):
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_warmup_all_runs_without_error(self):
tts = _reload_tts_asr_with_mocks()
# Set global models so warmup returns immediately without actual loading
tts._tts_model = MagicMock()
async def run(): # type: ignore (unused var)
await tts._warmup_all()
asyncio.get_event_loop().run_until_complete(run()) # type: ignore
class TestRouteRegistration:
"""路由注册测试"""
def test_register_function_exists(self):
tts = _reload_tts_asr_with_mocks()
assert callable(tts.register_tts_asr_routes)
def test_router_prefix(self):
tts = _reload_tts_asr_with_mocks()
assert hasattr(tts.router, 'routes')
class TestModelConstants:
"""模型常量测试"""
def test_asr_model_id(self):
tts = _reload_tts_asr_with_mocks()
assert 'Qwen3-ASR' in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts = _reload_tts_asr_with_mocks()
assert 'ForcedAligner' in tts.ALIGN_MODEL_ID_MS
-263
View File
@@ -1,263 +0,0 @@
import os
import sys
import base64
import io
import types
import wave
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _make_mlx_stub():
"""Create minimal MLX stub for testing without Apple Silicon"""
mlx = types.SimpleNamespace()
mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
mlx.core.array = mx_array
def mock_load(path):
return MagicMock()
mlx.core.load = mock_load # type: ignore
mlx.nn = types.SimpleNamespace()
return mlx
def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path): # type: ignore
model = MagicMock()
output = types.SimpleNamespace()
output.text = "识别结果"
output.language = "zh-CN"
model.generate = MagicMock(return_value=output)
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
mlx_stub = _make_mlx_stub()
sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr
return tts_asr, audio_stub
@pytest.fixture(autouse=True)
def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {}
for k in ['HF_ENDPOINT']:
saved[k] = os.environ.get(k)
if k in os.environ:
del os.environ[k]
yield
for k, v in saved.items():
if v is not None:
os.environ[k] = v # type: ignore
def _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
"""Helper: generate WAV bytes as base64"""
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples * channels, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return base64.b64encode(buf.getvalue()).decode()
class TestASRLazyLoading:
"""测试 ASR 模型懒加载"""
def test_ensure_asr_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._asr_model is None
model = tts._ensure_asr_model()
assert model is not None
def test_ensure_align_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._align_model is None
model = tts._ensure_align_model()
assert model is not None
class TestASREndpoint:
"""测试 ASR 端点逻辑"""
def test_asr_basic_recognition(self, fastapi_testclient=None):
"""ASR 端点应正确返回识别结果"""
tts, _ = _reload_tts_asr_with_mocks()
# Mock the model to return known values
tts._asr_model = MagicMock()
output = types.SimpleNamespace()
output.text = "你好世界"
output.language = "zh-CN"
tts._asr_model.generate.return_value = output
wav_b64 = _make_wav_bytes()
req = tts.ASRRequest(audio_base64=wav_b64)
# Call generate directly (simulating endpoint logic)
audio_bytes = base64.b64decode(req.audio_base64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
raw = wf.readframes(wf.getnframes())
arr = np.frombuffer(raw, dtype=np.int16)
arr = arr.astype(np.float32) / 32768.0
result = tts._asr_model.generate(arr, language=req.language)
assert result.text == "你好世界"
def test_asr_stereo_to_mono(self):
"""立体声音频应被正确转换为单声道"""
wav_b64 = _make_wav_bytes(channels=2)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getnchannels() == 2
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Convert to mono
audio_array = np.mean(audio_array.reshape(-1, 2), axis=1)
assert audio_array.ndim == 1
def test_asr_resample_to_16k(self):
"""非 16kHz 音频应被重采样"""
wav_b64 = _make_wav_bytes(sr=48000, duration_sec=0.5)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getframerate() == 48000
def test_asr_44100_resample(self):
"""44.1kHz 常见采样率应被重采样到 16k"""
wav_b64 = _make_wav_bytes(sr=44100, duration_sec=1.0)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Simulate resample calculation
if framerate != 16000:
n_samples = int(len(audio_array) * 16000 / framerate)
else:
n_samples = len(audio_array)
expected_16k_samples = int(1.0 * 16000)
assert abs(n_samples - expected_16k_samples) < 2
class TestASRModelDownload:
"""测试 ASR 模型下载路径"""
def test_load_asr_from_path_success(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/asr'): # type: ignore
tts._load_asr_models()
assert tts._asr_model is not None
def test_load_asr_skips_without_mlx(self):
"""不注入 MLX stub 时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_load_align_from_path(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/align'): # type: ignore
tts._load_asr_models()
assert tts._align_model is not None
class TestModelConstants:
"""测试模型 ID 常量"""
def test_asr_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "aufklarer" in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "ForcedAligner" in tts.ALIGN_MODEL_ID_MS
def test_tts_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "Qwen3-TTS" in tts.MODEL_ID_MS
class TestHFEndpointMirror:
"""测试镜像站配置"""
def test_hf_endpoint_set(self):
tts, _ = _reload_tts_asr_with_mocks()
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
def test_hf_endpoint_default(self):
"""即使环境变量未设置,模块也应默认设置镜像"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
if "HF_ENDPOINT" in os.environ:
del os.environ["HF_ENDPOINT"]
import tts_asr # noqa: F811
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
-305
View File
@@ -1,305 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TTS/ASR模块集成测试 MLX/Qwen3-ASR 版本
测试API端点和完整流程需要运行后端服务
运行方式:
pytest backend/tests/test_tts_asr_integration.py -v -s
python backend/tests/test_tts_asr_integration.py --test asr
MLX 模型通过 ModelScope (aufklarer/Qwen3-ASR) + ForcedAligner
"""
import argparse
import base64
import io
import os
import sys
import time
import unittest
from typing import Optional
try:
import httpx # type: ignore
except ImportError:
print("httpx 未安装,跳过集成测试")
sys.exit(1)
import numpy as np
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:8001')
API_KEY = os.environ.get('API_KEY', 'your-secret-key-here')
TEST_TIMEOUT = 120.0
class TTSASRIntegrationTest(unittest.TestCase):
"""TTS/ASR集成测试"""
@classmethod
def setUpClass(cls):
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
cls.headers = {'X-API-Key': API_KEY}
try:
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
if response.status_code == 200:
cls.service_available = True
print(f"\n✓ 服务可用: {API_BASE_URL}")
else:
cls.service_available = False
print(f"\n✗ 服务返回非200状态码: {response.status_code}")
except Exception as e: # noqa: ANN001
cls.service_available = False
print(f"\n✗ 无法连接到服务: {e}")
@classmethod
def tearDownClass(cls):
cls.client.close()
def setUp(self):
if not self.service_available:
self.skipTest("后端服务不可用")
def test_01_config_endpoint(self):
"""测试配置端点"""
response = self.client.get(
f'{API_BASE_URL}/v1/tts-asr/config',
headers=self.headers
)
self.assertEqual(response.status_code, 200)
config = response.json()
self.assertIn('device', config)
self.assertIn('model', config)
self.assertIn('status', config)
model = config['model']
status = config['status']
self.assertIn('tts', model)
self.assertIn('asr', model)
print(f"\n配置信息:")
print(f" TTS模型: {model['tts']}")
print(f" ASR模型: {model.get('asr', 'N/A')}")
print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR已加载: {status['asr_loaded']}")
def test_02_status_endpoint(self):
"""测试状态端点"""
response = self.client.get(
f'{API_BASE_URL}/v1/tts-asr/status',
headers=self.headers
)
self.assertEqual(response.status_code, 200)
status = response.json()
self.assertIn('tts_loaded', status)
self.assertIn('asr_loaded', status)
self.assertIn('device', status)
print(f"\n状态信息:")
print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR已加载: {status['asr_loaded']}")
print(f" 设备: {status['device']}")
def test_03_warmup_endpoint(self):
"""测试预热端点"""
print("\n开始模型预热(可能需要几分钟)...")
start_time = time.time()
response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/warmup',
headers=self.headers,
)
elapsed = time.time() - start_time
self.assertEqual(response.status_code, 200)
result = response.json()
self.assertIn('tts_warmup', result)
self.assertIn('asr_warmup', result)
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}")
print(f" ASR预热: {'成功' if result.get('asr_warmup') else '失败/跳过'}")
if not result['tts_warmup'] or not result.get('asr_warmup'):
print("\n⚠ 警告: 预热失败可能是因为模型未下载")
def test_04_tts_endpoint_basic(self):
"""测试TTS基本功能"""
test_text = "这是一个测试"
response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/tts',
headers=self.headers,
json={'text': test_text}
)
if response.status_code == 500:
error = response.json()
print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
self.skipTest("TTS模型未加载或不可用")
self.assertEqual(response.status_code, 200)
result = response.json()
self.assertIn('audio_base64', result)
self.assertIn('format', result)
self.assertIn('duration_ms', result)
audio_data = base64.b64decode(result['audio_base64'])
self.assertGreater(len(audio_data), 0)
print(f"\nTTS测试成功:")
print(f" 输入文本: {test_text}")
print(f" 音频大小: {len(audio_data)} bytes")
def test_05_asr_endpoint_basic(self):
"""测试ASR基本功能"""
sample_rate = 16000
duration = 1.0
samples = int(sample_rate * duration)
silence = np.zeros(samples, dtype=np.int16)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wf: # noqa: SIM115
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(silence.tobytes())
audio_bytes = wav_buffer.getvalue()
audio_base64 = base64.b64encode(audio_bytes).decode()
response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/asr',
headers=self.headers,
json={
'audio_base64': audio_base64,
'language': 'zh-CN'
}
)
if response.status_code in (500, 501):
detail = response.json().get('detail', 'Unknown')
print(f"\n⚠ ASR失败: {detail}")
self.skipTest("ASR模型未加载或不可用")
self.assertEqual(response.status_code, 200)
result = response.json()
self.assertIn('text', result)
self.assertIn('language', result)
print(f"\nASR测试成功:")
print(f" 识别文本: '{result['text']}'")
print(f" 语言: {result['language']}")
def test_06_api_key_validation(self):
"""测试API密钥验证"""
wrong_headers = {'X-API-Key': 'wrong-api-key'}
response = self.client.get(
f'{API_BASE_URL}/v1/tts-asr/status',
headers=wrong_headers,
)
self.assertEqual(response.status_code, 403)
class PerformanceTest(unittest.TestCase):
"""性能测试"""
@classmethod
def setUpClass(cls):
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
cls.headers = {'X-API-Key': API_KEY}
try:
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
cls.service_available = response.status_code == 200
except Exception: # noqa: ANN001, S110
cls.service_available = False
@classmethod
def tearDownClass(cls):
cls.client.close()
def setUp(self):
if not self.service_available:
self.skipTest("后端服务不可用")
def test_tts_latency(self):
"""测试TTS延迟"""
latencies = []
for i in range(3):
start = time.time()
response = self.client.post(
f'{API_BASE_URL}/v1/tts-asr/tts',
headers=self.headers,
json={'text': '测试延迟'}
)
elapsed = time.time() - start
if response.status_code == 200:
latencies.append(elapsed)
if latencies:
print(f"\nTTS延迟测试:")
print(f" 平均: {sum(latencies)/len(latencies):.3f}s")
print(f" 最小: {min(latencies):.3f}s / 最大: {max(latencies):.3f}s")
def run_tests(test_type: Optional[str] = None) -> bool:
"""运行测试"""
loader = unittest.TestLoader()
suite = unittest.TestSuite()
TEST_MAP = {
'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'),
'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'),
'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'),
'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'),
'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'),
'perf': ('PerformanceTest', None),
}
if test_type and test_type in TEST_MAP:
cls_name, method = TEST_MAP[test_type]
if method:
suite.addTest(globals()[cls_name](method))
else:
suite.addTests(loader.loadTestsFromTestCase(globals()[cls_name]))
elif test_type == 'api_key':
suite.addTest(TTSASRIntegrationTest('test_06_api_key_validation'))
else:
suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest))
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return result.wasSuccessful()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='TTS/ASR 集成测试')
parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key'])
parser.add_argument('--url', default=API_BASE_URL)
parser.add_argument('--key', default=API_KEY)
args = parser.parse_args()
API_BASE_URL = args.url
API_KEY = args.key
print("=" * 70)
print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)")
print("=" * 70)
success = run_tests(args.test)
sys.exit(0 if success else 1)
-156
View File
@@ -1,156 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TTS/ASR模块单元测试 测试核心功能无需实际运行模型
MLX/Qwen3-ASR 版本仅测试数据模型设备检测等轻量逻辑
运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov
"""
import base64
import io
import os
import sys
import unittest
import wave
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import numpy as np
class TestRequestResponseModels(unittest.TestCase):
"""测试请求/响应数据模型"""
def test_asr_request_defaults(self):
from backend.tts_asr import ASRRequest
req = ASRRequest(audio_base64="dGVzdA==")
self.assertEqual(req.audio_base64, "dGVzdA==")
self.assertEqual(req.language, "zh-CN")
def test_asr_request_with_language(self):
from backend.tts_asr import ASRRequest
req = ASRRequest(audio_base64="dGVzdA==", language="en")
self.assertEqual(req.language, "en")
def test_asr_response(self):
from backend.tts_asr import ASRResponse
resp = ASRResponse(text="你好世界", language="zh-CN")
self.assertEqual(resp.text, "你好世界")
self.assertEqual(resp.language, "zh-CN")
def test_tts_request_defaults(self):
from backend.tts_asr import TTSRequest
req = TTSRequest(text="测试文本")
self.assertEqual(req.text, "测试文本")
self.assertEqual(req.speaker, "Vivian")
self.assertEqual(req.format, "wav")
def test_model_status(self):
from backend.tts_asr import ModelStatus
status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps")
self.assertFalse(status.tts_loaded)
self.assertTrue(status.asr_loaded)
self.assertEqual(status.device, "mps")
class TestDeviceDetection(unittest.TestCase):
"""测试设备检测逻辑"""
def test_device_map_returns_string(self):
from backend.tts_asr import _get_device_map
device = _get_device_map()
self.assertIsInstance(device, str)
class TestAudioDecoding(unittest.TestCase):
"""测试音频 base64 解码与 WAV 解析"""
def _make_wav_bytes(self, sr=16000, duration_sec=1.0):
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return buf.getvalue()
def test_decode_valid_wav(self):
"""有效 WAV 应能正常解码"""
wav_bytes = self._make_wav_bytes()
audio_b64 = base64.b64encode(wav_bytes).decode()
decoded = base64.b64decode(audio_b64)
wav_buffer = io.BytesIO(decoded)
with wave.open(wav_buffer, 'rb') as wf:
self.assertEqual(wf.getframerate(), 16000)
self.assertEqual(wf.getnchannels(), 1)
def test_decode_empty_raises(self):
"""空 base64 解码后 wave.open 应抛出异常"""
decoded = base64.b64decode("")
self.assertEqual(decoded, b"") # Python 3: empty base64 -> empty bytes
wav_buffer = io.BytesIO(decoded)
with self.assertRaises(Exception):
wave.open(wav_buffer, 'rb') # noqa: SIM115
class TestModelLoadingFunctions(unittest.TestCase):
"""测试模型加载函数存在性(不实际下载)"""
@patch.object(sys.modules.get('backend.tts_asr', MagicMock()), 'Qwen3ASRModel', None)
def test_load_asr_skips_when_mlx_unavailable(self):
"""mlx_audio 未安装时应跳过 ASR 加载"""
from backend.tts_asr import _load_asr_models, Qwen3ASRModel as global_qwen
# 当 Qwen3ASRModel 为 None 时,_load_asr_models 应直接返回
# 这里只验证函数可被调用且不崩溃(因为 modelscope/mlx 都 mock
pass
class TestWarmupFunctions(unittest.TestCase):
"""测试预热函数存在性"""
def test_warmup_functions_exist(self):
from backend.tts_asr import _warmup_tts, _warmup_all
self.assertTrue(callable(_warmup_tts))
self.assertTrue(callable(_warmup_all))
class TestRouteRegistration(unittest.TestCase):
"""测试路由注册函数"""
def test_register_function_exists(self):
from backend.tts_asr import register_tts_asr_routes
self.assertTrue(callable(register_tts_asr_routes))
def run_tests():
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for cls in (TestRequestResponseModels, TestDeviceDetection,
TestAudioDecoding, TestModelLoadingFunctions,
TestWarmupFunctions, TestRouteRegistration):
suite.addTests(loader.loadTestsFromTestCase(cls))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return result.wasSuccessful()
if __name__ == '__main__':
success = run_tests()
sys.exit(0 if success else 1)
+156
View File
@@ -0,0 +1,156 @@
import asyncio
import importlib
import os
import sys
import threading
from pathlib import Path
from fastapi.testclient import TestClient
os.environ["JOB_BACKEND"] = "memory"
os.environ["DOCS_BACKEND"] = "memory"
CURRENT_DIR = Path(__file__).resolve().parent
BACKEND_DIR = CURRENT_DIR.parent
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import job_handlers # type: ignore
import job_system # type: ignore
import llm # type: ignore
import risk_control # type: ignore
import session_store # type: ignore
import audit_store # type: ignore
main = importlib.import_module("main")
HEADERS = {"X-API-Key": main.API_KEY}
def setup_function():
job_system.reset_job_manager()
risk_control.reset_risk_controller()
session_store.reset_session_store()
audit_store.reset_audit_store()
main._handlers_registered = False
def _payload():
return {
"prefix": "比较当前主流向量数据库的设计差异",
"suffix": "",
"languageId": "markdown",
"privacy_mode": True,
}
def test_is_blocked_public_url():
assert job_handlers._is_blocked_public_url("http://127.0.0.1/test") is True
assert job_handlers._is_blocked_public_url("file:///tmp/test") is True
assert job_handlers._is_blocked_public_url("https://example.com/docs") is False
def test_web_search_route_returns_done(monkeypatch):
async def fake_call_ollama(prompt, system_prompt=None, tag="", **kwargs): # noqa: ARG001
if tag.endswith("-webq"):
return {"content": '["vector database comparison", "pinecone weaviate qdrant"]'}
if tag.endswith("-webu"):
return {"content": '["https://example.com/a", "https://example.com/b"]'}
raise AssertionError(f"unexpected tag: {tag}")
async def fake_stream_ollama_events(prompt, system_prompt=None, tag="", **kwargs): # noqa: ARG001
if not tag.endswith("-webf"):
raise AssertionError(f"unexpected stream tag: {tag}")
yield "content", "第一段\n\n"
yield "content", "第二段"
async def fake_searxng_search(query, *, limit): # noqa: ARG001
return [
{
"title": "Doc A",
"url": "https://example.com/a",
"score": 9.1,
"published_date": "2026-06-08",
"snippet": "snippet a",
},
{
"title": "Doc B",
"url": "https://example.com/b",
"score": 8.8,
"published_date": "2026-06-07",
"snippet": "snippet b",
},
]
async def fake_firecrawl_scrape(url):
return {"url": url, "title": f"title for {url}", "markdown": f"content for {url}"}
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
monkeypatch.setattr(job_handlers, "_searxng_search", fake_searxng_search)
monkeypatch.setattr(job_handlers, "_firecrawl_scrape", fake_firecrawl_scrape)
monkeypatch.setattr(llm, "stream_ollama_events", fake_stream_ollama_events)
with TestClient(main.app) as client:
with client.stream("POST", "/v1/web-search", headers=HEADERS, json=_payload()) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: progress" in body
assert "keywords" in body
assert "searching" in body
assert "selecting_urls" in body
assert "crawling" in body
assert "synthesizing" in body
assert "event: done" in body
assert "第一段" in body
def test_cancel_web_search(monkeypatch):
started = threading.Event()
cancelled = threading.Event()
async def fake_call_ollama(*args, **kwargs):
tag = kwargs.get("tag", "")
if tag.endswith("-webq"):
started.set()
try:
while True:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
cancelled.set()
raise
return {"content": "[]"}
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
request_id = "req-web-search-cancel"
with TestClient(main.app) as client:
response_box = {}
def send_request():
with client.stream(
"POST",
"/v1/web-search",
headers={**HEADERS, "X-Request-Id": request_id},
json=_payload(),
) as response:
response_box["status_code"] = response.status_code
response_box["body"] = "".join(response.iter_text())
search_thread = threading.Thread(target=send_request, daemon=True)
search_thread.start()
assert started.wait(timeout=2.0)
cancel_response = client.post(
"/v1/web-search/cancel",
headers=HEADERS,
json={"request_id": request_id, "reason": "abort"},
)
assert cancel_response.status_code == 200
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
search_thread.join(timeout=5.0)
assert not search_thread.is_alive()
assert cancelled.wait(timeout=2.0)
assert "event: cancelled" in response_box["body"]
+170 -332
View File
@@ -1,254 +1,133 @@
import asyncio
import base64
import io
import logging
import os
import tempfile
import wave
from typing import Optional
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
import torch
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# New TTS model import
try:
import numpy as np # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("numpy import failed: %s", exc)
np = None # type: ignore
try:
import torch # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("torch import failed: %s", exc)
torch = None # type: ignore
try:
from qwen_tts import Qwen3TTSModel # type: ignore
except Exception as e: # pragma: no cover
logger.debug("qwen_tts import failed (optional): %s", e)
except Exception as exc: # pragma: no cover
logger.debug("qwen_tts import failed: %s", exc)
Qwen3TTSModel = None # type: ignore
# ASR model import (MLX-based, Apple Silicon only)
try:
from mlx_audio.stt.models.qwen3_asr import ( # type: ignore
ForcedAlignerModel,
Qwen3ASRModel,
)
except Exception as e: # pragma: no cover
logger.debug("mlx_audio import failed (optional): %s", e)
Qwen3ASRModel = None # type: ignore
ForcedAlignerModel = None # type: ignore
from faster_whisper import WhisperModel # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("faster_whisper import failed: %s", exc)
WhisperModel = None # type: ignore
try:
from modelscope import snapshot_download # type: ignore
except Exception as e: # pragma: no cover
logger.debug("modelscope import failed (optional): %s", e)
except Exception as exc: # pragma: no cover
logger.debug("modelscope import failed: %s", exc)
snapshot_download = None # type: ignore
router = APIRouter()
meta_router = APIRouter()
generation_router = APIRouter()
# Global model instances
_tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional[object] = None # Qwen3ASRModel or ForcedAlignerModel
_align_model: Optional[object] = None # Qwen3-ForcedAlignerModel
# Model paths for loading
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
ASR_MODEL_ID = os.getenv("ASR_MODEL_ID", "small")
ASR_COMPUTE_TYPE = os.getenv("ASR_COMPUTE_TYPE", "int8")
# ModelScope ASR/ForcedAligner models (MLX 4-bit format)
ASR_MODEL_ID_MS = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
ALIGN_MODEL_ID_MS = "aufklarer/Qwen3-ForcedAligner-0.6B-MLX"
_tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional["WhisperModel"] = None
def _get_device_map() -> str:
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
if torch is None:
return "cpu"
if torch.cuda.is_available():
return "cuda:0"
return "cuda"
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except Exception as e: # noqa: ANN001
logger.debug("MPS check failed: %s", e)
except Exception as exc: # pragma: no cover
logger.debug("MPS check failed: %s", exc)
return "cpu"
def _download_model_from_modelscope() -> Optional[str]:
"""从 ModelScope 下载模型到本地缓存目录"""
try:
def _download_tts_model_from_modelscope() -> Optional[str]:
if snapshot_download is None:
return None
cache_dir = os.path.join(os.path.dirname(__file__), "models")
os.makedirs(cache_dir, exist_ok=True)
model_dir = snapshot_download(
MODEL_ID_MS,
cache_dir=cache_dir,
revision="master"
)
logger.info("ModelScope 模型下载完成: %s", model_dir)
return model_dir
except Exception as e: # noqa: ANN001
logger.warning("ModelScope 下载失败: %s", e)
try:
return snapshot_download(MODEL_ID_MS, cache_dir=cache_dir, revision="master")
except Exception as exc: # pragma: no cover
logger.warning("ModelScope TTS download failed: %s", exc)
return None
async def _warmup_tts():
"""预热 TTS 模型"""
await asyncio.to_thread(_load_tts_model_with_retry)
async def _warmup_asr():
"""预热 ASR 模型(从 ModelScope 下载并加载)"""
await asyncio.to_thread(_load_asr_models)
async def _warmup_all():
"""预热所有模型(TTS 和 ASR"""
logger.info("[Warmup] 开始预热 TTS 模型...")
await _warmup_tts()
logger.info("[Warmup] TTS 模型预热完成")
if Qwen3ASRModel is not None:
logger.info("[Warmup] 开始预热 ASR 模型...")
await _warmup_asr()
logger.info("[Warmup] ASR 模型预热完成")
def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
"""加载 TTS 模型,支持多个镜像源"""
def _ensure_tts_model() -> "Qwen3TTSModel":
global _tts_model
if _tts_model is not None:
return _tts_model
if Qwen3TTSModel is None:
raise RuntimeError("qwen_tts 库未安装,无法加载 TTS 模型")
if np is None or torch is None or Qwen3TTSModel is None:
raise RuntimeError("TTS 依赖未安装完整")
device_map = _get_device_map()
last_err = None
dtype = torch.float16 if device_map != "cpu" else torch.float32
# 策略1: 尝试从 ModelScope 下载后加载
for attempt in range(max_retries):
model_path = _download_tts_model_from_modelscope()
last_error = None
for candidate in [model_path, MODEL_ID_HF]:
if not candidate:
continue
try:
logger.info("尝试从 ModelScope 下载 TTS 模型...")
model_path = _download_model_from_modelscope()
if model_path and os.path.isdir(model_path):
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
model_path,
candidate,
device_map=device_map,
dtype=torch.float16,
dtype=dtype,
)
logger.info("ModelScope TTS 模型加载成功: %s", model_path)
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
except Exception as exc:
last_error = exc
logger.warning("TTS model load failed from %s: %s", candidate, exc)
# 策略2: 尝试从 HuggingFace 镜像加载
for attempt in range(max_retries):
try:
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
MODEL_ID_HF,
device_map=device_map,
dtype=torch.float16,
)
logger.info("HuggingFace TTS 模型加载成功")
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err
raise RuntimeError(f"TTS 模型加载失败: {last_error}") from last_error
def _load_asr_models() -> None:
"""从 ModelScope 下载并加载 ASR/ForcedAligner MLX 模型"""
global _asr_model, _align_model
if snapshot_download is None:
logger.warning("modelscope 未安装,跳过 ASR 模型加载")
return
if Qwen3ASRModel is None:
logger.warning("mlx_audio 未安装,跳过 ASR 模型加载")
return
# Download and load ASR model from ModelScope
try:
logger.info("从 ModelScope 下载 ASR 模型...")
asr_cache_dir = os.path.join(os.path.dirname(__file__), "models", "asr")
asr_model_dir = snapshot_download(ASR_MODEL_ID_MS, cache_dir=asr_cache_dir)
_load_asr_from_path(asr_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_asr_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ASR hf-mirror 加载失败,跳过 ASR: %s", e2)
# Download and load ForcedAligner model from ModelScope
try:
logger.info("从 ModelScope 下载 ForcedAligner 模型...")
align_cache_dir = os.path.join(os.path.dirname(__file__), "models", "aligner")
align_model_dir = snapshot_download(ALIGN_MODEL_ID_MS, cache_dir=align_cache_dir)
_load_align_from_path(align_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ForcedAligner ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_align_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ForcedAligner hf-mirror 加载失败,跳过: %s", e2)
def _load_asr_from_path(model_dir: str) -> None:
"""从本地路径加载 ASR MLX 模型"""
def _ensure_asr_model() -> "WhisperModel":
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
if _asr_model is not None:
return _asr_model
if WhisperModel is None:
raise RuntimeError("faster-whisper 未安装")
model = stt_load(model_dir)
_asr_model = model
logger.info("ASR 模型加载成功 (路径: %s)", model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR MLX 加载失败,尝试直接构建: %s", e)
try:
from mlx.core import load as mx_load # type: ignore
weights = mx_load(os.path.join(model_dir, "model.safetensors"))
from mlx_lm import load as lm_load # type: ignore
model = lm_load(model_dir, model_cls=Qwen3ASRModel)
_asr_model = model
except Exception as e2: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型: {e2}") from e
device = "cuda" if _get_device_map() == "cuda" else "cpu"
compute_type = ASR_COMPUTE_TYPE if device == "cpu" else "float16"
_asr_model = WhisperModel(ASR_MODEL_ID, device=device, compute_type=compute_type)
return _asr_model
def _load_asr_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ASR-0.6B-4bit")
_asr_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ASR MLX: {e}") from e
async def _warmup_tts():
await asyncio.to_thread(_ensure_tts_model)
def _load_align_from_path(model_dir: str) -> None:
"""从本地路径加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {model_dir}): {e}") from e
def _load_align_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ForcedAligner-0.6B-4bit")
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ForcedAligner MLX: {e}") from e
async def _warmup_asr():
await asyncio.to_thread(_ensure_asr_model)
class TTSRequest(BaseModel):
@@ -280,43 +159,25 @@ class ModelStatus(BaseModel):
device: str
def _ensure_tts_model() -> "Qwen3TTSModel":
"""确保 TTS 模型已加载"""
global _tts_model
if _tts_model is None:
_tts_model = _load_tts_model_with_retry()
return _tts_model
def _normalize_language(language: Optional[str]) -> Optional[str]:
if not language:
return None
value = language.strip().lower()
if value in {"auto", ""}:
return None
mapping = {
"zh-cn": "zh",
"zh-hans": "zh",
"zh-tw": "zh",
"en-us": "en",
"ja-jp": "ja",
"ko-kr": "ko",
}
return mapping.get(value, value.split("-")[0])
def _ensure_asr_model():
"""确保 ASR 模型已加载(懒加载)"""
global _asr_model
if _asr_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_asr_model = stt_load(ASR_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型 (路径: {ASR_MODEL_ID_MS}): {e}") from e
return _asr_model
def _ensure_align_model():
"""确保 ForcedAligner 模型已加载(懒加载)"""
global _align_model
if _align_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_align_model = stt_load(ALIGN_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {ALIGN_MODEL_ID_MS}): {e}") from e
return _align_model
@router.get("/status", response_model=ModelStatus)
@meta_router.get("/status", response_model=ModelStatus)
async def get_status():
"""获取模型状态"""
return ModelStatus(
tts_loaded=_tts_model is not None,
asr_loaded=_asr_model is not None,
@@ -324,13 +185,12 @@ async def get_status():
)
@router.get("/config")
@meta_router.get("/config")
async def get_config():
"""获取配置信息"""
return {
"model": {
"tts": MODEL_ID_MS,
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
"asr": ASR_MODEL_ID,
},
"device": _get_device_map(),
"status": {
@@ -340,151 +200,129 @@ async def get_config():
}
@router.post("/warmup")
@meta_router.post("/warmup")
async def warmup_models():
"""手动触发模型预热"""
await _warmup_tts()
if Qwen3ASRModel is not None:
await _warmup_asr()
return {
"tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
"asr_warmup": _asr_model is not None,
"device": _get_device_map(),
}
@router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest):
"""TTS 文字转语音端点"""
async def generate_tts_response(
text: str,
instruct: str = "",
speaker: str = "Vivian",
output_format: str = "wav",
) -> TTSResponse:
del speaker
del output_format
if np is None:
raise HTTPException(status_code=501, detail="numpy 未安装,TTS 功能不可用")
try:
model = _ensure_tts_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=str(e))
text = req.text
instruct = req.instruct or ""
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
try:
# VoiceDesign 模型使用 generate_voice_design 方法
wavs, sr = model.generate_voice_design( # type: ignore
wavs, sample_rate = await asyncio.to_thread(
model.generate_voice_design, # type: ignore
text=text,
language="Chinese",
instruct=instruct,
instruct=instruct or "",
)
except Exception as e: # noqa: ANN001
logger.exception("TTS 推理失败")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
except Exception as exc:
logger.exception("TTS inference failed")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {exc}")
# Get first audio data
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
# Convert to numpy array
if hasattr(wav_data, 'numpy'): # type: ignore
wav_data = wav_data.cpu().numpy() # type: ignore
if hasattr(wav_data, "cpu"):
wav_data = wav_data.cpu().numpy()
wav_data = np.asarray(wav_data, dtype=np.float32)
logger.debug("wav_data shape: %s, dtype: %s, sr: %s", wav_data.shape, wav_data.dtype, sr)
# Encode WAV to memory
tmp_path = None
try:
import soundfile as sf # type: ignore
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd) # type: ignore
sf.write(tmp_path, wav_data, sr)
with open(tmp_path, "rb") as f: # noqa: SIM115
audio_bytes = f.read()
except Exception as e: # noqa: ANN001
logger.exception("音频编码失败")
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
os.close(fd)
sf.write(tmp_path, wav_data, sample_rate)
with open(tmp_path, "rb") as handle:
audio_bytes = handle.read()
except Exception as exc:
logger.exception("TTS audio encode failed")
raise HTTPException(status_code=500, detail=f"音频编码失败: {exc}")
finally:
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
try:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e: # noqa: ANN001
pass
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
duration_ms = int(len(wav_data) / sample_rate * 1000) if sample_rate > 0 else 0
return TTSResponse(
audio_base64=audio_base64,
audio_base64=base64.b64encode(audio_bytes).decode("utf-8"),
format="wav",
duration_ms=duration_ms,
)
@router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
"""语音识别端点(非流式)"""
if Qwen3ASRModel is None:
raise HTTPException(status_code=501, detail="mlx_audio 未安装,ASR 功能不可用")
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
if not audio_bytes:
raise HTTPException(status_code=400, detail="音频内容为空")
try:
model = _ensure_asr_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {exc}")
normalized_language = _normalize_language(language)
tmp_path = None
try:
# Decode base64 audio to WAV bytes
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
with open(tmp_path, "wb") as handle:
handle.write(audio_bytes)
segments, info = await asyncio.to_thread(
model.transcribe,
tmp_path,
language=normalized_language,
vad_filter=True,
beam_size=5,
)
text = "".join(segment.text for segment in segments).strip()
if not text:
raise RuntimeError("ASR 返回结果为空")
detected_language = getattr(info, "language", normalized_language or "unknown")
return ASRResponse(text=text, language=str(detected_language))
except HTTPException:
raise
except Exception as exc:
logger.exception("ASR inference failed")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {exc}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
@generation_router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest):
return await generate_tts_response(
text=req.text,
instruct=req.instruct or "",
speaker=req.speaker,
output_format=req.format,
)
@generation_router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
audio_bytes = base64.b64decode(req.audio_base64)
# Load WAV file and convert to 16kHz mono numpy array
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf: # noqa: SIM115
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32)
# Convert to mono
if n_channels > 1:
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1)
# Resample to 16kHz if needed
if framerate != 16000:
try:
import scipy.signal as signal # type: ignore
n_samples = int(len(audio_array) * 16000 / framerate)
audio_array = signal.resample(audio_array, n_samples) # type: ignore
except Exception as e2: # noqa: ANN001
logger.warning("重采样失败,使用原始音频: %s", e2)
# Convert to float32 normalized
if audio_array.dtype == np.int16:
audio_array = audio_array.astype(np.float32) / 32768.0
# Run ASR inference (non-streaming)
result = model.generate( # type: ignore
audio_array,
language=req.language if req.language else None,
)
# Extract text and detected language from result (STTOutput)
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result)
detected_lang = getattr(result, 'language', req.language or "zh-CN")
# If language is a list (from segments), take the first one
if isinstance(detected_lang, list) and len(detected_lang) > 0:
detected_lang = detected_lang[0]
return ASRResponse(
text=recognized_text,
language=str(detected_lang),
)
except Exception as e: # noqa: ANN001
logger.exception("ASR 推理失败")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}")
return await generate_asr_response(audio_bytes, req.language if req.language else None)
def register_tts_asr_routes(app):
"""注册 TTS/ASR 路由到 FastAPI 应用"""
app.include_router(router, prefix="/v1/tts-asr")
def register_tts_asr_routes(app, include_generation_routes: bool = True):
app.include_router(meta_router, prefix="/v1/tts-asr")
if include_generation_routes:
app.include_router(generation_router, prefix="/v1/tts-asr")
+41
View File
@@ -0,0 +1,41 @@
import asyncio
import logging
from job_handlers import (
asr_handler,
completion_handler,
compress_handler,
convert_handler,
ocr_handler,
pro_completion_handler,
tts_handler,
web_search_handler,
)
from job_system import RedisJobManager, RedisWorker
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("worker")
async def main() -> None:
manager = RedisJobManager()
manager.register_handler("completion", completion_handler)
manager.register_handler("pro_completion", pro_completion_handler)
manager.register_handler("web_search", web_search_handler)
manager.register_handler("compress", compress_handler)
manager.register_handler("ocr", ocr_handler)
manager.register_handler("convert", convert_handler)
manager.register_handler("tts", tts_handler)
manager.register_handler("asr", asr_handler)
worker = RedisWorker(manager)
try:
await worker.run_forever()
finally:
await manager.close()
if __name__ == "__main__":
asyncio.run(main())
+132
View File
@@ -0,0 +1,132 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
args:
DOCKER_REGISTRY_PREFIX: ${DOCKER_REGISTRY_PREFIX:-}
depends_on:
- api
ports:
- "8080:80"
postgres:
image: ${DOCKER_REGISTRY_PREFIX:-}postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-llm_in_text}
POSTGRES_USER: ${POSTGRES_USER:-llm_in_text}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-llm_in_text_change_me}
volumes:
- ./docker-data/postgres:/var/lib/postgresql/data
redis:
image: ${DOCKER_REGISTRY_PREFIX:-}redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./docker-data/redis:/data
searxng:
image: ${DOCKER_REGISTRY_PREFIX:-}searxng/searxng:latest
restart: unless-stopped
environment:
BASE_URL: http://searxng:8080/
INSTANCE_NAME: llm-in-text-search
volumes:
- ./docker-data/searxng:/etc/searxng
firecrawl-postgres:
image: ${DOCKER_REGISTRY_PREFIX:-}postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: firecrawl
POSTGRES_USER: firecrawl
POSTGRES_PASSWORD: firecrawl
volumes:
- ./docker-data/firecrawl-postgres:/var/lib/postgresql/data
firecrawl-rabbitmq:
image: ${DOCKER_REGISTRY_PREFIX:-}rabbitmq:3-management-alpine
restart: unless-stopped
volumes:
- ./docker-data/firecrawl-rabbitmq:/var/lib/rabbitmq
firecrawl-playwright:
image: ghcr.io/firecrawl/playwright-service:latest
restart: unless-stopped
environment:
PORT: 3000
firecrawl:
image: ghcr.io/firecrawl/firecrawl:latest
restart: unless-stopped
environment:
HOST: 0.0.0.0
PORT: 3002
POSTGRES_HOST: firecrawl-postgres
POSTGRES_PORT: 5432
POSTGRES_DB: firecrawl
POSTGRES_USER: firecrawl
POSTGRES_PASSWORD: firecrawl
REDIS_URL: redis://redis:6379/1
REDIS_RATE_LIMIT_URL: redis://redis:6379/1
NUQ_RABBITMQ_URL: amqp://guest:guest@firecrawl-rabbitmq:5672/
PLAYWRIGHT_MICROSERVICE_URL: http://firecrawl-playwright:3000/scrape
SEARXNG_ENDPOINT: http://searxng:8080
USE_DB_AUTHENTICATION: "false"
FIRECRAWL_API_KEY: ${FIRECRAWL_API_KEY:-change-me}
depends_on:
- redis
- firecrawl-postgres
- firecrawl-rabbitmq
- firecrawl-playwright
api:
build:
context: .
dockerfile: backend/Dockerfile
args:
DOCKER_REGISTRY_PREFIX: ${DOCKER_REGISTRY_PREFIX:-}
env_file:
- backend/.env
environment:
JOB_BACKEND: redis
REDIS_URL: redis://redis:6379/0
DATABASE_URL: ${DATABASE_URL:-postgresql://llm_in_text:llm_in_text_change_me@postgres:5432/llm_in_text}
DOCS_BACKEND: postgres
JOB_SHARED_TEMP_DIR: /shared-jobs
SEARXNG_BASE_URL: http://searxng:8080
FIRECRAWL_BASE_URL: http://firecrawl:3002
depends_on:
- postgres
- redis
- searxng
- firecrawl
ports:
- "8001:8001"
volumes:
- ./docker-data/jobs:/shared-jobs
worker:
build:
context: .
dockerfile: backend/Dockerfile
args:
DOCKER_REGISTRY_PREFIX: ${DOCKER_REGISTRY_PREFIX:-}
command: ["python", "worker.py"]
env_file:
- backend/.env
environment:
JOB_BACKEND: redis
REDIS_URL: redis://redis:6379/0
DATABASE_URL: ${DATABASE_URL:-postgresql://llm_in_text:llm_in_text_change_me@postgres:5432/llm_in_text}
DOCS_BACKEND: postgres
JOB_SHARED_TEMP_DIR: /shared-jobs
SEARXNG_BASE_URL: http://searxng:8080
FIRECRAWL_BASE_URL: http://firecrawl:3002
depends_on:
- postgres
- redis
- searxng
- firecrawl
volumes:
- ./docker-data/jobs:/shared-jobs
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /v1/ {
return 307 https://api.imageteach.tech:8002$request_uri;
}
}
@@ -1,142 +0,0 @@
# Announcing Telemetry Inspector
There's a lot of questions from community asking that how can they know what plugins are enabled.
From Milkdown@7.2, we've added telemetries for milkdown, it can be available by inspectors.
With this API, you can inspect editor inner status.
You can even use visualizer to visualize the data. We create a simple example on [our playground](/playground).
![Milkdown Inspector](/blogs/announcing-telemetry-inspector/milkdown-inspector.gif)
## Get Started
Inspector will be a top-level API in Milkdown. You can use it like this:
```ts
import { Editor } from "@milkdown/core";
import { Telemetry } from "@milkdown/ctx";
const editor = await Editor.make()
// Inspector is disabled by default considering performance. You need to enable it manually.
.enableInspector()
// ...
.create();
const telemetry: Telemetry[] = editor.inspect();
```
The `Telemetry` interface will have the following fields:
```ts
interface Telemetry {
// User defined information for the plugin.
metadata: Meta;
// The slices and their current value defined by the plugin.
injectedSlices: { name: string; value: unknown }[];
// The slices and their current value consumed by the plugin.
consumedSlices: { name: string; value: unknown }[];
// The timers and their duration defined by the plugin.
recordedTimers: { name: string; duration: number; status: TimerStatus }[];
// The timers and their duration consumed by the plugin.
// Generally, the plugin will wait for them.
waitTimers: { name: string; duration: number; status: TimerStatus }[];
}
type TimerStatus = "pending" | "resolved" | "rejected";
interface Meta {
displayName: string;
description?: string;
package: string;
group?: string;
additional?: Record<string, any>;
}
```
For every plugin, it'll have a telemetry if it has metadata declared.
With the data, you'll know the sequence of the plugins loaded, the slices and timers they defined and consumed.
For example:
```ts
[
{
metadata: {
displayName: "Config",
package: "@milkdown/core",
group: "System",
},
injectedSlices: [],
consumedSlices: [
/* ... */
],
recordedTimers: [
{
name: "ConfigReady",
duration: 3,
status: "resolved",
},
],
waitTimers: [],
},
{
metadata: {
displayName: "Init",
package: "@milkdown/core",
group: "System",
},
injectedSlices: [],
consumedSlices: [
/* ... */
],
recordedTimers: [
{
name: "InitReady",
duration: 5,
status: "resolved",
},
],
waitTimers: [
{
name: "ConfigReady",
duration: 5,
status: "resolved",
},
],
},
];
```
From above information, we can know that the `Init` plugin wait for `Config` plugin to be ready.
We can build a sequence diagram from the data.
![Timer Sequence](/blogs/announcing-telemetry-inspector/timer-sequence.gif)
## Add Metadata for Plugin
For plugin maintainers, you can add metadata to your plugin to make it more friendly to the inspector.
```ts
import { MilkdownPlugin } from "@milkdown/ctx";
const yourMilkdownPlugin: MilkdownPlugin = () => {
/* your implementation */
};
yourMilkdownPlugin.metadata = {
displayName: "Your Plugin",
package: "your-plugin-package",
description: "Your plugin description",
group: "If you have a lot of plugins in your package, you can group them.",
addtitional: {
/* You can add any additional information here. */
version: "1.0.0",
authror: "Mike",
},
};
```
With metadata, your plugin will report telemetry correctly to the inspector.
@@ -1,247 +0,0 @@
# Build Your Own Milkdown Copilot
OpenAI introduced ChatGPT in 2020, which is a chatbot that can generate natural language responses to user input.
Which brings us a new way to interact with devices and applications.
Nowadays, there are more and more tools that are powered by AI. Such as Notion, GitHub and even Microsoft 365.
Since OpenAI also released the [API](https://openai.com/blog/openai-api) of it. And Milkdown is composed by plugins.
I think it's possible to build a Milkdown Copilot Plugin that can help you write documents. So I did it.
Let's see the result.
![Milkdown Copilot](/blogs/build-your-own-milkdown-copilot/milkdown-copilot.gif)
Looks cool, right? But how does it work? I'll explain it in the following sections.
## Prepare a Backend
**Before we start, you need to have a OpenAI API Key.** You'll need to get one [here](https://platform.openai.com/account/api-keys).
I'll not explain how to get it. You can find the details in their [official docs](https://platform.openai.com/).
I'll use Node.js to build the backend. You can use any language you like.
The backend is very simple. It just calls the OpenAI API and returns the result.
```ts
import { Configuration, OpenAIApi } from "openai";
const configuration = new Configuration({
// Get your API key from env variable
apiKey: process.env.OPENAPI_KEY,
});
const openai = new OpenAIApi(configuration);
export const handler = async (req, res, next) => {
if (req.path === "/api/copilot" && req.method === "POST") {
const buffers = [];
// Get the body of the request.
const body = JSON.parse(req.body);
// Get prompt from the body.
const { prompt } = body;
const completion = await openai.createCompletion({
// Pick a model you like
model: "text-davinci-003",
prompt,
});
const hint = completion.data.choices[0].text;
return res.end(JSON.stringify({ hint }));
}
next();
return;
};
```
We watch the `/api/copilot` route and call the OpenAI API when we receive a POST request.
The post request should contain a `prompt` field which is the text that we want to complete.
To call our API, we just need one single helper in browser environment:
```ts
async function fetchAIHint(prompt: string) {
const data: Record<string, string> = { prompt };
const response = await fetch("/api/copilot", {
method: "POST",
body: JSON.stringify(data),
});
const res = (await response.json()) as { hint: string };
return res.hint;
}
```
## Build a Milkdown Plugin
Now let's focus on the Milkdown Copilot Plugin.
Basically I want to implement two things:
1. When the user types `<Enter>` or `<Space>`, they will get a hint from the copilot.
2. When the user types `<Tab>`, they will apply the content from the hint to the editor.
### Overview
To build a bridge between the copilot and the editor,
we can build a prosemirror plugin and use the `onKeyDown` hook to listen to the keydown event.
```ts
function keyDownHandler(ctx: Ctx, event: Event) {
if (event.key === "Enter" || event.code === "Space") {
getHint(ctx);
return;
}
if (event.key === "Tab") {
// prevent the browser from focusing on the next element.
event.preventDefault();
applyHint(ctx);
return;
}
hideHint(ctx);
}
```
When the user types `<Enter>` or `<Space>`, we will call the `getHint` function to get a hint from the copilot.
And when the user types `<Tab>`, we will call the `applyHint` function to apply the hint to the editor.
If user types other keys, we will hide the hint.
And we also need a component to render the hint. Here I choose to use a simple [widget decoration in prosemirror](https://prosemirror.net/docs/ref/#view.Decoration^widget).
```ts
function renderHint(message: string) {
const dom = document.createElement("pre");
dom.className = "copilot-hint";
dom.innerHTML = message;
return dom;
}
```
So our component looks like:
```ts
import { Plugin, PluginKey } from "@milkdown/prose/state";
import { Decoration, DecorationSet } from "@milkdown/prose/view";
import { $prose } from "@milkdown/utils";
const initialState = {
deco: DecorationSet.empty,
message: "",
};
export const copilotPluginKey = new PluginKey("milkdown-copilot");
export const copilotPlugin = $prose(
(ctx) =>
new Plugin({
key: copilotPluginKey,
props: {
handleKeyDwon(view, event) {
keydownHandler(ctx, event);
},
decorations(state) {
return copilotPluginKey.getState(state).deco;
},
},
state: {
init() {
return { ...initialState };
},
apply(tr, value, _prevState, state) {
const message = tr.getMeta(copilotPluginKey);
if (typeof message !== "string") return value;
if (message.length === 0) {
return { ...initialState };
}
const { to } = tr.selection;
const widget = Decoration.widget(to + 1, () => renderHint(message));
return {
deco: DecorationSet.create(state.doc, [widget]),
message,
};
},
},
}),
);
```
### Get Hint
To get a hint from the copilot, we need to get the text before the cursor.
```ts
function getHint(ctx: Ctx) {
const view = ctx.get(editorViewCtx);
const { state } = view;
const { tr, schema } = state;
const { from } = tr.selection;
const slice = tr.doc.slice(0, from);
const serializer = ctx.get(serializerCtx);
const doc = schema.topNodeType.createAndFill(undefined, slice.content);
if (!doc) return;
const markdown = serializer(doc);
fetchAIHint(markdown).then((hint) => {
const tr = view.state.tr;
view.dispatch(tr.setMeta(copilotPluginKey, hint));
});
}
```
1. First of all, we get the `selection` from the `state` of the editor.
2. Then we get a `slice` of the document from the start to the cursor.
3. Then we use the `serializer` to convert the slice to markdown.
4. After that, we call the `fetchAIHint` function to get a hint from the copilot.
5. Finally, we dispatch a transaction with the hint message we get to update the state of the editor.
### Hide Hint
To hide the hint, we just need to dispatch a transaction with an empty message.
```ts
function hideHint(ctx: Ctx) {
const view = ctx.get(editorViewCtx);
const { state } = view;
const { tr } = state;
view.dispatch(tr.setMeta(copilotPluginKey, ""));
}
```
### Apply Hint
Since we pass markdown to the OpenAI API. It may return a markdown snippet.
So, before we apply the hint to the editor, we need to convert the markdown snippet to prosemirror node.
```ts
function applyHint(ctx: Ctx) {
const view = ctx.get(editorViewCtx);
const { state } = view;
const { tr, schema } = state;
const { message } = copilotPluginKey.getState(state);
const parser = ctx.get(parserCtx);
const slice = parser(message);
const dom = DOMSerializer.fromSchema(schema).serializeFragment(slice.content);
const node = DOMParser.fromSchema(schema).parseSlice(dom);
// Reset the hint since it's applied
tr.setMeta(copilotPluginKey, "")
// Replace the selection with the hint
.replaceSelection(node);
view.dispatch(tr);
}
```
1. First of all, we get the hint message from the state of the editor.
2. Then we use the `parser` to convert the markdown snippet to prosemirror node.
3. Finally, we dispatch a transaction to replace the selection with the hint.
## Conclusion
In this article, we have built a really simple Copilot plugin for Milkdown.
The plugin is not perfect, but it's a good start to help you build your own.
The source code is available on [Milkdown/examples/vanilla-openapi](https://github.com/Milkdown/examples/tree/main/vanilla-openai).
I hope it can give you some inspiration.
@@ -1,151 +0,0 @@
# Introducing Milkdown@7
It's been almost one year since the release of [milkdown](https://milkdown.dev) V6.
It helped a lot of users to build their own markdown based applications.
It has 13k downloads per month and I feel so grateful that users like that.
However, we noticed that there're some problems cannot be resolved if we don't make a new major version.
What big changes did we made? I'll introduce them to you in this blog.
## TL;DR
- The editor becomes a first-class headless component.
- Factory plugins are fully replaced by **composable plugins**.
- Runtime plugin toggling is supported.
- Universal widget plugins.
- Better Vue and React support.
- API documentation is provided.
## Why Headless?
In the past, milkdown had a lot of internal styles to make sure the editor can work out of box and the themes are easy to create.
However, I found it limits the users to design their own editor.
Even worse, if you have an well designed application,
it is really hard to keep the style of the milkdown editor same with the rest of the application.
You'll need to override lots of styles everywhere.
It stops a log of users from using milkdown.
If we think about why users need an editor,
the most important thing is always the functionality of the editor.
Users just want a component that can provide smooth editing experience.
Style will always be the second thing.
So, why not remove all the internal styles and make the editor a headless component?
The users can easily integrate the editor into their own application.
They can use their own styles and even use their own components to render the editor.
We just care about the functionality of the editor. Make sure it works well.
## Composable Plugins
Although the composable plugins have been existed in milkdown for a long time,
we use factory plugins to create most of the official plugins in V6.
But, the problem is that factory plugins limit the possibility of the plugins.
The factory plugins handle a bunch of complex logic and it is hard to extend.
So for users who want to create a plugin in a easy way, they must follow the factory plugin's way.
```ts
const nodePlugin = createPlugin(() => ({
id: 'node',
schema: someSchema,
inputRules: someInputRules
commands: someCommands
}))
```
See? You can define a lot of things inside the factory plugin.
But if you want to use some part of them in another plugin, it's really hard to do that.
However, the milkdown's plugin system is designed to be flexible and composable.
We want to let users to control the data flow entirely.
So, we decided to remove all the factory plugins and use composable plugins to replace them.
The composable plugins can keep the atomicity of the plugins and make the plugin system more flexible.
They also make the plugin system easier to maintain.
```ts
const nodeSchema = $node("node", someSchema);
const nodeInputRules = $inputRules(someInputRules);
const nodeCommands = $commands(someCommands);
```
If you want to reuse them, it also will be very easy.
```ts
const anotherCommand = $commands(() => {
return setBlockType(nodeSchema.type());
});
```
## Runtime Plugin Toggling
In the past, once you register a plugin, you cannot remove it.
In V7, we support runtime plugin toggling by providing two new API: `editor.remove` and `editor.removeConfig`.
They can let users remove the plugins and configs at runtime.
```ts
import { Editor } from "@milkdown/core";
import { someMilkdownPlugin } from "some-milkdown-plugin";
const editor = await Editor.config(configForPlugin)
.use(someMilkdownPlugin)
.create();
// remove plugin
await editor.remove(someMilkdownPlugin);
// remove config
editor.removeConfig(configForPlugin);
// add another plugin
editor.use(anotherMilkdownPlugin);
// Recreate the editor to apply changes.
await editor.create();
```
Also, if you call the `editor.create` method after the editor is created,
it will recreate the editor and apply all the changes.
## Universal Widget Plugins
We have 4 official widget plugins in V6: _slash_, _tooltip_, _block_ and _menu_.
They are all well designed and easy to use.
But if you want to customize them, what you can do is really limited.
Also, it's hard to reuse their logic even if you want to create something similar to them.
For example, if you want to create a mention plugin which will show a list of users when you type `@`,
you need to create a new plugin from scratch.
So, in V7, we make _slash_, _tooltip_ and _block_ plugins universal.
You can use them to build you features easily.
For example, if you want to create a mention plugin, you can use the new slash plugin to do that.
Another example is that you can also create tooltips for different types of nodes.
Display a tooltip with input when you focus on an image node, or display a tooltip with buttons when you select some text.
What about the _menu_ plugin? We removed it because we think it's easy to create a menu plugin by yourself.
We've already done that in the [official playground](https://milkdown.dev/playground).
And, trust me, [it won't need much code](https://github.com/milkdown/website/blob/main/src/component/Playground/Milkdown/index.tsx#L57).
## Better Vue and React Support
Thanks to the [Saul-Mirone/prosemirror-adapter project](https://github.com/Saul-Mirone/prosemirror-adapter).
In milkdown V7. We allow users to use vue and react to render lots of parts of the editor.
For example, you can use them to render your own code block, drag handle or even small icons.
- React Example: [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/Milkdown/examples/tree/main/react-custom-component)
- Vue Example: [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/Milkdown/examples/tree/main/vue-custom-component)
## API Documentation
What's the hardest thing to do when maintaining an open source project?
Keep the documentation up to date.
Thanks to the [marijnh/builddocs project](https://github.com/marijnh/builddocs),
we can generate the API documentation automatically from the source code.
We also redesigned the documentation website, provide a more powerful playground and lots of examples.
@@ -1,172 +0,0 @@
# Understanding Headless Slash Plugin
In the old Milkdown versions. The slash plugin can be used to display a list of commands when users type `/` in the editor.
It provides a way to insert nodes and commands into the editor, and it's really easy to use.
![legacy slash plugin](/blogs/understanding-headless-slash-plugin/legacy-slash-plugin.png)
However, it's hard to extend the slash plugin to support more commands, or if you want to change the UI of the slash plugin, you have to rewrite the whole plugin.
But, write a new plugin is always a hard work. You have to understand a lot of context and APIs of both ProseMirror and Milkdown.
## User Story
So, why don't we provide the slash plugin as a headless plugin?
In most cases, developers just want to make sure that when users type a special character, a dropdown menu will be displayed.
But the trigger character and the UI of the dropdown menu are different in different cases.
For example:
- When user type `/`, the menu contains a list of **commands**.
- When user type `:`, the menu contains a list of **emoji**.
- When user type `@`, the menu contains a list of **users**.
That's the story behind the headless slash plugin. We provide the plugin to solve a single problem: **display a dropdown menu when users input satisfy a condition**.
## How to use
In the new slash plugin, you'll need to control when to display the dropdown menu by yourself.
And you'll also need to provide the UI of the dropdown menu.
So, you'll need to create a `SlashProvider` instance.
```ts
import { slashPlugin, SlashProvider } from "@milkdown/plugin-slash";
const slashProvider = new SlashProvider({
content: YourDropdownUI,
shouldShow(this: SlashProvider, view: EditorView) {
const currentText = this.getContent(view);
if (currentText === "") {
return false;
}
// Display the menu if the last character is `/`.
if (currentText.endsWith("/")) {
return true;
}
return false;
},
});
```
Then, you can use the slash provider in your plugin view.
```ts
import { EditorState } from "@milkdown/prose/state";
import { EditorView, PluginView } from "@milkdown/prose/view";
function yourSlashView(): PluginView {
return {
update: (view: EditorView, prevState: EditorState) => {
slashProvider.update(view, prevState);
},
destroy: () => {
slashProvider.destroy();
},
};
}
```
Last, you'll need to add the slash plugin to your editor.
```ts
import { Editor } from "@milkdown/core";
import { slashFactory } from "@milkdown/plugin-slash";
const slash = slashFactory("my-slash");
Editor.make()
.config((ctx) => {
ctx.set(slash.key, {
view: slashPluginView,
});
})
.use(slash)
.create();
```
## Use with Prosemirror Adapter
If you're using milkdown with UI frameworks like React,
I recommend you to use the [Prosemirror Adapter](https://github.com/Saul-Mirone/prosemirror-adapter).
It can help you build prosemirror UI components with your favorite UI framework.
For example, if you're using React:
```tsx
import { SlashProvider } from "@milkdown/plugin-slash";
import { useInstance } from "@milkdown/react";
import { usePluginViewContext } from "@prosemirror-adapter/react";
export const DropdownMenu = () => {
const { view, prevState } = usePluginViewContext();
const slashProvider = useRef<SlashProvider>();
const divRef = useRef<HTMLDivElement>(null);
const [loading] = useInstance();
useEffect(() => {
if (!ref.current || loading) return;
slashProvider.current ??= new SlashProvider({
content: divRef.current,
// ...
});
return () => {
slashProvider.current?.destroy();
slashProvider.current = undefined;
};
}, [loading, root, setOpened, setSearch, setSelected]);
useEffect(() => {
slashProvider.current?.update(view, prevState);
});
// Add a wrapper `div` to hide the dropdown menu when initializing.
return (
<div className="hidden">
<div role="tooltip" ref={divRef}>
<h1>Hi! I'm a dropdown menu.</h1>
</div>
</div>
);
};
```
And in your editor component:
```ts
import { usePluginViewFactory } from "@prosemirror-adapter/react";
export const YourEditor = () => {
const pluginViewFactory = usePluginViewFactory();
useEditor((editor) => {
return Editor.make()
.config((ctx) => {
ctx.set(slash.key, {
view: pluginViewFactory({
component: DopdownMenu,
}),
});
})
.use(slash);
});
// ...
};
```
## Real World Example
In [milkdown playground](/playground), you can type `/` to display a dropdown menu.
![command dropdown](/blogs/understanding-headless-slash-plugin/command-dropdown.png)
You can also type `:(\S)+` (for example: `:mil`) to display a list of emojis.
![emoji dropdown](/blogs/understanding-headless-slash-plugin/emoji-dropdown.png)
You can find the source code of them in [Milkdown website](https://github.com/Milkdown/website).
I hope you enjoy the new slash plugin.
@@ -1,199 +0,0 @@
# Architecture Overview
Milkdown is built with a modular, layered architecture that provides flexibility and extensibility. This document explains the core architectural concepts and how they work together.
![0.75](/guide/milkdown-architecture.png "Milkdown Architecture")
## Core Architecture Layers
Milkdown's architecture is built upon four distinct layers, each providing specific functionality and extensibility:
### 🥛 Core Layer
The foundation of Milkdown that provides:
- Plugin loading and management system
- Core editor concepts and interfaces
- Base document model integration
- Essential utilities and helpers
### 🧇 Plugin Layer
A comprehensive collection of modular plugins that extend the editor's functionality:
- Syntax plugins (Markdown parsing, GFM, etc.)
- UI plugins (toolbar, menu, etc.)
- Feature plugins (image upload, table, etc.)
- Utility plugins (history, clipboard, etc.)
### 🍮 Component Layer
Headless UI components that serve as building blocks:
- Toolbar components
- Slash menu components
- Table components
### 🍰 Editor Layer
Ready-to-use, user-friendly editors:
- Crepe editor
- Custom editor implementations
## Architecture Benefits
This layered approach provides several key benefits:
1. **Modularity**: Each layer can be used independently
2. **Flexibility**: Mix and match components as needed
3. **Extensibility**: Create custom implementations at any layer
4. **Maintainability**: Clear separation of concerns
5. **Reusability**: Components can be shared across implementations
## Markdown Transformation
![0.75](/guide/transformer.png "Transformer")
Milkdown's transformation system handles the conversion between Markdown and the editor's internal document model:
### Parsing Process
1. Markdown text → Remark AST
2. Remark AST → ProseMirror Schema
3. Schema → ProseMirror Document
### Serialization Process
1. ProseMirror Document → ProseMirror Schema
2. Schema → Remark AST
3. Remark AST → Markdown text
This transformation system ensures:
- Accurate Markdown parsing
- Consistent document structure
- Reliable serialization
- Extensible transformation pipeline
## Context System
The Context System is a powerful state management and dependency coordination system that enables plugins to work together seamlessly.
![1.00](/guide/plugin-sequence.png "Plugin Sequence")
### Core Concepts
#### 1. Context (Ctx)
The main interface for plugins to interact with the system:
```typescript
interface Ctx {
get: <T>(slice: Slice<T>) => T;
set: <T>(slice: Slice<T>, value: T) => void;
wait: (timer: Timer) => Promise<void>;
done: (timer: Timer) => void;
inject: <T>(slice: Slice<T>, value: T) => void;
remove: <T>(slice: Slice<T>) => void;
}
```
#### 2. Slices
State containers that can be shared between plugins:
```typescript
// Create a slice with initial value and name
const themeSlice = createSlice("light", "theme");
// Use in a plugin
const themePlugin: MilkdownPlugin = (ctx) => {
return () => {
// Read current theme
const theme = ctx.get(themeSlice);
// Update theme
ctx.set(themeSlice, "dark");
// React to theme changes
ctx.watch(themeSlice, (newTheme) => {
// Handle theme change
});
};
};
```
#### 3. Timers
Dependency management system for plugin coordination:
```typescript
// Define a timer
const dataReady = createTimer("DataReady");
// Use in a plugin
const dataPlugin: MilkdownPlugin = (ctx) => {
ctx.record(dataReady);
return async () => {
// Wait for dependencies
await ctx.wait(SchemaReady);
// Do work
// ...
// Mark as ready
ctx.done(dataReady);
};
};
```
### Plugin Lifecycle
Plugins follow a consistent lifecycle pattern:
```typescript
const examplePlugin: MilkdownPlugin = (ctx) => {
// 1. Setup Phase
ctx.inject(mySlice, defaultValue);
ctx.record(myTimer);
return async () => {
// 2. Initialization Phase
await ctx.wait(RequiredTimer);
// 3. Runtime Phase
const value = ctx.get(mySlice);
ctx.set(mySlice, newValue);
// 4. Cleanup Phase
return () => {
ctx.remove(mySlice);
};
};
};
```
### Best Practices
1. **State Management**
- Use slices for shared state
- Keep state minimal and focused
- Watch for state changes when needed
2. **Dependency Management**
- Use timers for coordination
- Wait for required dependencies
- Mark completion appropriately
3. **Plugin Organization**
- Follow the lifecycle pattern
- Clean up resources properly
- Document dependencies clearly
## Next Steps
- Start to [use Crepe editor](/docs/guide/using-crepe)
- Learn more about [writing plugins](/docs/plugin/plugins-101)
- Explore [available plugins](/docs/plugin/using-plugins)
-160
View File
@@ -1,160 +0,0 @@
# Code Highlighting
Milkdown supports syntax highlighting for code blocks through the `@milkdown/plugin-highlight` plugin. This plugin provides several options for highlighting code with different syntax highlighters.
## Installation
```bash
npm install @milkdown/plugin-highlight
```
## Basic Usage
The highlight plugin requires a parser to be configured. Here's a basic example using the Shiki parser:
```typescript
import { Editor } from "@milkdown/core";
import { commonmark } from "@milkdown/preset-commonmark";
import { highlight, highlightPluginConfig } from "@milkdown/plugin-highlight";
import { createParser } from "@milkdown/plugin-highlight/shiki";
const editor = Editor.make()
.config(async (ctx) => {
const parser = await createParser({
theme: "github-light",
langs: ["javascript", "typescript", "python", "html", "css"],
});
ctx.set(highlightPluginConfig.key, { parser });
})
.use(commonmark)
.use(highlight)
.create();
```
## Available Parsers
The plugin supports multiple syntax highlighting libraries:
### Shiki
Provides high-quality syntax highlighting with VS Code themes. Learn more at [Shiki](https://shiki.style/):
```typescript
import { createParser } from "@milkdown/plugin-highlight/shiki";
const parser = await createParser({
theme: "github-light",
langs: ["javascript", "typescript", "python"],
});
ctx.set(highlightPluginConfig.key, { parser });
```
### Lowlight
Based on [highlight.js](https://highlightjs.org/), supports many languages:
```typescript
import { createParser } from "@milkdown/plugin-highlight/lowlight";
import { common } from "lowlight";
const parser = createParser({ common });
ctx.set(highlightPluginConfig.key, { parser });
```
Learn more about Lowlight at [lowlight](https://github.com/wooorm/lowlight).
### Refractor
Based on [Prism.js](https://prismjs.com/):
```typescript
import { createParser } from "@milkdown/plugin-highlight/refractor";
import { refractor } from "refractor";
const parser = createParser({ refractor });
ctx.set(highlightPluginConfig.key, { parser });
```
Learn more about Refractor at [refractor](https://github.com/wooorm/refractor).
### Sugar High
A lightweight and fast syntax highlighter. Learn more at [Sugar High](https://github.com/huozhi/sugar-high):
```typescript
import { createParser } from "@milkdown/plugin-highlight/sugar-high";
const parser = createParser();
ctx.set(highlightPluginConfig.key, { parser });
```
## Styling
The highlighted code will have CSS classes applied based on the chosen parser. You'll need to include appropriate CSS to style the highlighted tokens.
### Sugar High Classes
Sugar High uses classes like:
- `sh__token--identifier`
- `sh__token--string`
- `sh__token--keyword`
- `sh__token--sign`
- `sh__token--property`
You can style these using CSS variables:
```css
.sh__token--identifier {
color: var(--sh-identifier);
}
.sh__token--string {
color: var(--sh-string);
}
.sh__token--keyword {
color: var(--sh-keyword);
}
```
### Other Parsers
For Lowlight, Refractor, and Shiki, refer to their respective documentation for styling information.
## Example
Here's a complete example with Shiki:
```typescript
import { Editor } from "@milkdown/core";
import { commonmark } from "@milkdown/preset-commonmark";
import { highlight, highlightPluginConfig } from "@milkdown/plugin-highlight";
import { createParser } from "@milkdown/plugin-highlight/shiki";
async function createHighlightedEditor() {
const parser = await createParser({
theme: "github-light",
langs: ["javascript", "typescript", "python", "html", "css", "json"],
});
const editor = Editor.make()
.config((ctx) => {
ctx.set(highlightPluginConfig.key, { parser });
})
.use(commonmark)
.use(highlight);
await editor.create();
return editor;
}
```
With this setup, your code blocks will be automatically highlighted:
````markdown
```javascript
console.log("Hello, world!");
const greeting = (name) => `Hello, ${name}!`;
```
````
The code above will render with syntax highlighting applied to keywords, strings, and other language constructs.
@@ -1,122 +0,0 @@
# Collaborative Editing
Milkdown supports collaborative editing powered by [Y.js](https://docs.yjs.dev/).
We provide the [@milkdown/plugin-collab](/docs/api/plugin-collab) plugin to help you use milkdown with yjs easily.
This plugin includes basic collaborative editing features like:
- Sync between clients.
- Remote cursor support.
- Undo/Redo support.
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-collab"}
## Configure Plugin
First you need to install the plugin and yjs through npm:
```bash
npm install @milkdown/plugin-collab
npm install yjs y-protocols y-prosemirror
```
And you also need to choose a [provider for yjs](https://docs.yjs.dev/ecosystem/connection-provider), here we use [y-websocket](https://docs.yjs.dev/ecosystem/connection-provider/y-websocket) as an example.
After the installation, you can configure your editor:
```typescript
// ...import other plugins
import { collab, collabServiceCtx } from "@milkdown/plugin-collab";
async function setup() {
const editor = await Editor.make()
.config(nord)
.use(commonmark)
.use(collab)
.create();
const doc = new Doc();
const wsProvider = new WebsocketProvider("<YOUR_WS_HOST>", "milkdown", doc);
editor.action((ctx) => {
const collabService = ctx.get(collabServiceCtx);
collabService
// bind doc and awareness
.bindDoc(doc)
.setAwareness(wsProvider.awareness)
// connect yjs with milkdown
.connect();
});
}
```
Now your editor can support collaborative editing. Isn't it easy?
## Connect and Disconnect
You may want to control the connect status of the editor manually.
```typescript
editor.action((ctx) => {
const collabService = ctx.get(collabServiceCtx);
const doc = new Doc();
const wsProvider = new WebsocketProvider("<YOUR_WS_HOST>", "milkdown", doc);
collabService.bindDoc(doc).setAwareness(wsProvider.awareness);
document.getElementById("connect").onclick = () => {
wsProvider.connect();
collabService.connect();
};
document.getElementById("disconnect").onclick = () => {
wsProvider.disconnect();
collabService.disconnect();
};
});
```
## Default Template
By default, the editor will show a empty document. You may want to use a template to show a document.
```typescript
const template = `# Heading`;
editor.action((ctx) => {
const collabService = ctx.get(collabServiceCtx);
const doc = new Doc();
const wsProvider = new WebsocketProvider("<YOUR_WS_HOST>", "milkdown", doc);
collabService.bindDoc(doc).setAwareness(wsProvider.awareness);
wsProvider.once("synced", async (isSynced: boolean) => {
if (isSynced) {
collabService
// apply your template
.applyTemplate(markdown)
// don't forget connect
.connect();
}
});
});
```
Keep in mind that applying a template multiple times may cause some unexpected behavior, such as duplicate content.
Because of this you need to make sure **the template is applied only once**.
By default, the template will only be applied if _document get from remote server is empty_.
You can control this behavior through passing second parameter to `applyTemplate`:
```typescript
collabService
.applyTemplate(markdown, (remoteNode, templateNode) => {
// return true to apply template
})
// don't forget connect
.connect();
```
Here the nodes we get are [prosemirror nodes](https://prosemirror.net/docs/ref/#model.Node).
You should return `true` if the template should be applied, and `false` if not.
-250
View File
@@ -1,250 +0,0 @@
# Commands
Commands are a powerful way to programmatically modify editor content. The command system in Milkdown provides a flexible and type-safe way to create, manage, and execute commands.
## Command Manager
---
The command manager is the central place for handling all editor commands. It provides methods to:
- Register new commands
- Execute commands
- Chain multiple commands together
- Handle command arguments
## Run a Command
---
You can execute commands using the command manager through the editor's action system:
```typescript
import { Editor, commandsCtx } from "@milkdown/kit/core";
import {
commonmark,
toggleEmphasisCommand,
} from "@milkdown/kit/preset/commonmark";
async function setup() {
const editor = await Editor.make().use(commonmark).create();
const toggleItalic = () =>
editor.action((ctx) => {
// get command manager
const commandManager = ctx.get(commandsCtx);
// call command
commandManager.call(toggleEmphasisCommand.key);
});
// get markdown string:
$button.onClick = toggleItalic;
}
```
## Command Chaining
---
You can chain multiple commands together using the command manager's `chain` method. Commands in the chain will be executed in order until one of them returns `true`:
```typescript
import { Editor, commandsCtx } from "@milkdown/kit/core";
import {
commonmark,
toggleEmphasisCommand,
toggleStrongCommand,
} from "@milkdown/kit/preset/commonmark";
const editor = await Editor.make().use(commonmark).create();
editor.action((ctx) => {
const commandManager = ctx.get(commandsCtx);
// Chain multiple commands
commandManager
.chain()
.pipe(toggleEmphasisCommand.key) // Try to toggle emphasis
.pipe(toggleStrongCommand.key) // If emphasis fails, try to toggle strong
.run();
});
```
You can also mix inline commands with registered commands:
```typescript
import { chainCommands } from "@milkdown/prose/commands";
editor.action((ctx) => {
const commandManager = ctx.get(commandsCtx);
commandManager
.chain()
.inline(someInlineCommand) // Add an inline command
.pipe(toggleEmphasisCommand.key) // Add a registered command
.run();
});
```
## Create a Command
---
To create a command, use the `$command` utility from `@milkdown/utils`. Commands should be [prosemirror commands](https://prosemirror.net/docs/guide/#commands).
### Example: Command without argument
```typescript
import { Editor } from "@milkdown/kit/core";
import { blockquoteSchema } from "@milkdown/kit/preset/commonmark";
import { wrapIn } from "@milkdown/kit/prose/commands";
import { $command, callCommand } from "@milkdown/kit/utils";
const wrapInBlockquoteCommand = $command(
"WrapInBlockquote",
(ctx) => () => wrapIn(blockquoteSchema.type(ctx)),
);
// register the command when creating the editor
const editor = Editor().make().use(wrapInBlockquoteCommand).create();
// call command
editor.action(callCommand(wrapInBlockquoteCommand.key));
```
### Example: Command with argument
Commands can accept arguments of any type:
```typescript
import { headingSchema } from "@milkdown/kit/preset/commonmark";
import { setBlockType } from "@milkdown/kit/prose/commands";
import { $command, callCommand } from "@milkdown/kit/utils";
// use number as the type of argument
export const WrapInHeading = createCmdKey<number>();
const wrapInHeadingCommand = $command(
"WrapInHeading",
(ctx) =>
(level = 1) =>
setBlockType(headingSchema.type(ctx), { level }),
);
// call command
editor.action(callCommand(wrapInHeadingCommand.key)); // turn to h1 by default
editor.action(callCommand(wrapInHeadingCommand.key, 2)); // turn to h2
```
### Example: Command with Multiple Arguments
```typescript
interface TableConfig {
rows: number;
cols: number;
withHeader: boolean;
}
const insertTableCommand = $command(
"InsertTable",
(ctx) => (config: TableConfig) => {
// Implementation for inserting a table
return (state, dispatch) => {
// ... table insertion logic
return true;
};
},
);
// Usage
editor.action(
callCommand(insertTableCommand.key, {
rows: 3,
cols: 3,
withHeader: true,
}),
);
```
## Best Practices
---
1. **Command Naming**
- Use clear, descriptive names
- Follow the pattern: `[Action][Target]Command`
- Example: `toggleEmphasisCommand`, `insertTableCommand`
2. **Command Organization**
- Group related commands together
- Use namespaces for command keys
- Keep commands focused and single-purpose
3. **Error Handling**
- Always check if the command can be executed
- Return `false` if the command cannot be executed
- Handle edge cases gracefully
4. **Performance**
- Keep commands lightweight
- Avoid unnecessary state updates
- Use command chaining for complex operations
5. **Type Safety**
- Use TypeScript for command arguments
- Define clear interfaces for command payloads
- Use generics for type-safe command keys
## Common Patterns
---
### Toggle Commands
```typescript
const toggleCommand = $command(
"ToggleFeature",
(ctx) => () => (state, dispatch) => {
const isActive = checkIfActive(state);
return isActive
? removeFeature(state, dispatch)
: addFeature(state, dispatch);
},
);
```
### Insert Commands
```typescript
const insertCommand = $command(
"InsertContent",
(ctx) => (content: string) => (state, dispatch) => {
const { selection } = state;
if (!selection) return false;
const tr = state.tr.insertText(content, selection.from);
dispatch?.(tr);
return true;
},
);
```
### Transform Commands
```typescript
const transformCommand = $command(
"TransformContent",
(ctx) => (transform: (node: ProseNode) => ProseNode) => (state, dispatch) => {
const { selection } = state;
if (!selection) return false;
const tr = state.tr.replaceWith(
selection.from,
selection.to,
transform(state.doc.nodeAt(selection.from)!),
);
dispatch?.(tr);
return true;
},
);
```
-39
View File
@@ -1,39 +0,0 @@
# FAQ
This page lists answers of FAQ.
---
### How can I change contents programmatically?
You should use `editor.action` to change the contents.
We provide two macros for that allow you to change content in milkdown, `insert` and `replaceAll`.
```typescript
import { insert, replaceAll } from "@milkdown/kit/utils";
const editor = await Editor.make()
// .use(<All Your Plugins>)
.create();
editor.action(insert("# New Heading"));
editor.action(replaceAll("# New Document"));
```
---
### How to configure remark?
```typescript
import { remarkStringifyOptionsCtx } from "@milkdown/kit/core";
editor.config((ctx) => {
ctx.set(remarkStringifyOptionsCtx, {
// some options, for example:
bullet: "*",
fences: true,
incrementListMarker: false,
});
});
```
-165
View File
@@ -1,165 +0,0 @@
# Getting Started with Milkdown
Milkdown is a powerful WYSIWYG markdown editor that combines the simplicity of markdown with the flexibility of a modern editor. It's designed to be lightweight yet extensible, making it perfect for both simple and complex editing needs.
## Quick Start
The fastest way to get started is using `@milkdown/crepe`:
```bash
npm install @milkdown/crepe
```
```typescript
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
const crepe = new Crepe({
root: "#app",
defaultValue: "Hello, Milkdown!",
});
crepe.create();
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/editor-crepe"}
## Core Concepts
Milkdown consists of two main parts:
1. **Core Package** (`@milkdown/core`)
- Plugin loader
- Internal plugins
2. **Additional Plugins**
- Syntax support
- Commands
- UI components
- Custom features
This modular architecture allows you to enable or disable features as needed, from basic markdown support to advanced features like tables, LaTeX equations, and collaborative editing.
## Key Features
- 📝 **WYSIWYG Markdown** - Write markdown in an elegant way
- 🎨 **Themable** - Create your own theme and publish it as an npm package
- 🎮 **Hackable** - Create your own plugin to support your awesome idea
- 🦾 **Reliable** - Built on top of [prosemirror](https://prosemirror.net/) and [remark](https://github.com/remarkjs/remark)
- ⚡ **Slash & Tooltip** - Write faster than ever, enabled by a plugin
- 🧮 **Math** - LaTeX math equations support via math plugin
- 📊 **Table** - Table support with fluent ui, via table plugin
- 🍻 **Collaborate** - Shared editing support with [yjs](https://docs.yjs.dev/)
- 💾 **Clipboard** - Support copy and paste markdown, via clipboard plugin
- 👍 **Emoji** - Support emoji shortcut and picker, via emoji plugin
## Tech Stack
Milkdown is built on top of these powerful libraries:
- [Prosemirror](https://prosemirror.net/) - A toolkit for building rich-text editors on the web
- [Remark](https://github.com/remarkjs/remark) - Markdown parser done right
- [TypeScript](https://www.typescriptlang.org/) - For type safety and better developer experience
## Creating Your First Editor
Milkdown provides two distinct approaches to create an editor, each suited for different needs:
### 1. 🍼 Using `@milkdown/kit` (Build from Scratch)
This approach gives you complete control over your editor. Use this if you want to:
- Build a custom editor from the ground up
- Have full control over which features to include
- Create a highly customized editing experience
- Integrate with specific frameworks or requirements
First, install the required packages:
```bash
npm install @milkdown/kit
```
Create a basic editor with commonmark syntax:
```typescript
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
// This is the must have css for prosemirror
import "@milkdown/kit/prose/view/style/prosemirror.css";
Editor.make().use(commonmark).create();
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-commonmark"}
Add undo & redo support:
```typescript
import { Editor } from "@milkdown/kit/core";
import { history } from "@milkdown/kit/plugin/history";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
import "@milkdown/theme-nord/style.css";
const milkdown = Editor.make()
.config(nord)
.use(commonmark)
.use(history)
.create()
.then(() => {
console.log("Editor created");
});
// To destroy the editor
milkdown.destroy();
```
> **Note**: `<Mod>` is `<Cmd>` for macOS and `<Ctrl>` for other platforms.
### 2. 🥞 Using `@milkdown/crepe` (Ready to Use)
This is the quickest way to get started with a fully-featured editor. Use this if you want to:
- Get up and running quickly
- Have a well-designed editor out of the box
- Focus on content rather than configuration
- Have a production-ready solution with minimal setup
```bash
npm install @milkdown/crepe
```
```typescript
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
/**
* Available themes:
* frame, classic, nord
* frame-dark, classic-dark, nord-dark
*/
import "@milkdown/crepe/theme/frame.css";
const crepe = new Crepe({
root: "#app",
defaultValue: "Hello, Milkdown!",
});
crepe.create().then(() => {
console.log("Editor created");
});
// To destroy the editor
crepe.destroy();
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/editor-crepe"}
## Next Steps
- Learn more about [overview](/guide/architecture-overview)
- Explore [available plugins](/plugins/using-plugins)
- Check out [theming](/guide/theming)
> 🍼 Fun fact: This documentation is rendered by Milkdown itself!
@@ -1,398 +0,0 @@
# Interacting with Editor
This guide covers the essential ways to interact with the Milkdown editor, including initialization, content management, and editor lifecycle.
## Using Crepe Editor
---
Crepe is a high-level wrapper around Milkdown that provides a simpler API for common editor operations. Here's how to use it:
```typescript
import { Crepe } from "@milkdown/crepe";
// Create a new editor instance
const editor = new Crepe({
// Optional: specify root element (DOM node or selector)
root: "#editor",
// Optional: set default content, supports markdown, json and dom.
defaultValue: "# Hello Crepe!",
});
// Create the editor
await editor.create();
// Get markdown content
const markdown = editor.getMarkdown();
// Set readonly mode
editor.setReadonly(true);
// Register event listeners
editor.on((listener) => {
listener.markdownUpdated((ctx, markdown) => {
console.log("Content updated:", markdown);
});
listener.focus((ctx) => {
console.log("Editor focused");
});
listener.blur((ctx) => {
console.log("Editor blurred");
});
listener.selectionUpdated((ctx, selection, prevSelection) => {
console.log("Selection updated:", selection);
});
listener.updated((ctx, doc, prevDoc) => {
console.log("Document updated:", doc);
});
});
// Destroy the editor when done
await editor.destroy();
```
## Register to DOM
---
By default, milkdown will create editor on the `document.body`. Alternatively, you can also point out which dom node you want it to load into:
```typescript
import { rootCtx } from "@milkdown/kit/core";
Editor.make().config((ctx) => {
ctx.set(rootCtx, document.querySelector("#editor"));
});
```
It's also possible to just pass a selector to `rootCtx`:
> The selector will be passed to `document.querySelector` to get the dom.
```typescript
import { rootCtx } from "@milkdown/kit/core";
Editor.make().config((ctx) => {
ctx.set(rootCtx, "#editor");
});
```
## Setting Default Value
---
We support three types of default values:
- Markdown strings
- HTML DOM
- Prosemirror documentation JSON
### Markdown
You can set a markdown string as the default value of the editor.
```typescript
import { defaultValueCtx } from "@milkdown/kit/core";
const defaultValue = "# Hello milkdown";
Editor.make().config((ctx) => {
ctx.set(defaultValueCtx, defaultValue);
});
```
### Dom
You can also use HTML as default value.
Let's assume that we have the following html snippets:
```html
<div id="pre">
<h1>Hello milkdown!</h1>
</div>
```
Then we can use it as a defaultValue with a `type` specification:
```typescript
import { defaultValueCtx } from "@milkdown/kit/core";
const defaultValue = {
type: "html",
dom: document.querySelector("#pre"),
};
Editor.make().config((ctx) => {
ctx.set(defaultValueCtx, defaultValue);
});
```
### JSON
We can also use a JSON object as a default value.
This JSON object can be obtained by a listener through the [listener-plugin](https://www.npmjs.com/package/@milkdown/plugin-listener), for example:
```typescript
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
let jsonOutput;
Editor.make()
.config((ctx) => {
ctx.get(listenerCtx).updated((ctx, doc, prevDoc) => {
jsonOutput = doc.toJSON();
});
})
.use(listener);
```
Then we can use this `jsonOutput` as default Value:
```typescript
import { defaultValueCtx } from "@milkdown/kit/core";
const defaultValue = {
type: "json",
value: jsonOutput,
};
Editor.make().config((ctx) => {
ctx.set(defaultValueCtx, defaultValue);
});
```
## Inspecting Editor Status
---
You can inspect the editor's status through the `status` property.
```typescript
import { Editor, EditorStatus } from "@milkdown/kit/core";
const editor = Editor.make().use(/* some plugins */);
assert(editor.status === EditorStatus.Idle);
editor.create().then(() => {
assert(editor.status === EditorStatus.Created);
});
assert(editor.status === EditorStatus.OnCreate);
editor.destroy().then(() => {
assert(editor.status === EditorStatus.Destroyed);
});
assert(editor.status === EditorStatus.OnDestroyed);
```
You can also listen to the status changes:
```typescript
import { Editor, EditorStatus } from "@milkdown/kit/core";
const editor = Editor.make().use(/* some plugins */);
editor.onStatusChange((status: EditorStatus) => {
console.log(status);
});
```
### Status Lifecycle
1. `Idle`: Initial state
2. `OnCreate`: During creation
3. `Created`: Successfully created
4. `OnDestroyed`: During destruction
5. `Destroyed`: Successfully destroyed
## Adding Listeners
---
As mentioned above, you can add a listener to the editor, in order to get its value when needed.
You can add as many listeners as you want, all the listeners will be triggered at once.
### Markdown Listener
You can add markdown listener to get the editor's contents as a markdown string.
> ⚠️ Markdown listener will influence the performance for large documents, please use it carefully.
> If you have a large document, I suggest you to only `parse` and `serialize` the document when needed.
```typescript
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
let output = "";
Editor.make()
.config((ctx) => {
ctx.get(listenerCtx).markdownUpdated((ctx, markdown, prevMarkdown) => {
output = markdown;
});
})
.use(listener);
```
### Doc Listener
You can also listen to the [raw prosemirror document node](https://prosemirror.net/docs/ref/#model.Node), and do things you want from there.
```typescript
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
let jsonOutput;
Editor.make()
.config((ctx) => {
ctx.get(listenerCtx).updated((ctx, doc, prevDoc) => {
jsonOutput = doc.toJSON();
});
})
.use(listener);
```
### Selection Listener
You can track changes to the editor's selection using the `selectionUpdated` event. This is useful for implementing features like:
- Custom toolbars that update based on selection
- Context menus
- Selection-based formatting controls
```typescript
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
import { Selection, TextSelection } from "@milkdown/prose/state";
Editor.make()
.config((ctx) => {
ctx.get(listenerCtx).selectionUpdated((ctx, selection, prevSelection) => {
if (selection instanceof TextSelection) {
// Get selection range
const { from, to } = selection;
// Example: Update toolbar based on selection
updateToolbar({
hasSelection: from !== to,
selectionStart: from,
selectionEnd: to,
});
}
});
})
.use(listener);
```
The selection listener will be triggered when the selection is changed.
So you don't need to compare them manually.
For more details about listeners, please check [Using Listeners](/docs/api/plugin-listener).
## Readonly Mode
---
You can set the editor to readonly mode by setting the `editable` property.
```typescript
import { editorViewOptionsCtx } from "@milkdown/kit/core";
let readonly = false;
const editable = () => !readonly;
Editor.make().config((ctx) => {
ctx.update(editorViewOptionsCtx, (prev) => ({
...prev,
editable,
}));
});
// set to readonly after 5 secs.
setTimeout(() => {
readonly = true;
}, 5000);
```
### Use Cases for Readonly Mode
- Preview mode
- Document review
- Print-friendly views
- Mobile device optimization
## Using Actions
---
You can use an action to get the context value in a running editor on demand.
For example, to get the markdown string by running an action:
```typescript
import { Editor, editorViewCtx, serializerCtx } from "@milkdown/kit/core";
async function playWithEditor() {
const editor = await Editor.make().use(commonmark).create();
const getMarkdown = () =>
editor.action((ctx) => {
const editorView = ctx.get(editorViewCtx);
const serializer = ctx.get(serializerCtx);
return serializer(editorView.state.doc);
});
// get markdown string:
getMarkdown();
}
```
We provide some macros out of the box, you can use them as actions:
```typescript
import { insert } from "@milkdown/kit/utils";
editor.action(insert("# Hello milkdown"));
```
### Common Actions
- Insert content
- Get current selection
- Apply formatting
- Execute commands
For more details about macros, please check [macros](/docs/guide/macros).
## Destroying
---
You can call `editor.destroy` to destroy an existing editor. You can create a new editor again with `editor.create`.
```typescript
await editor.destroy();
// Then create again
await editor.create();
```
If you just want to recreate the editor, you can use `editor.create`, it will **destroy the old editor and create a new one**.
```typescript
await editor.create();
// This equals to call `editor.destroy` and `editor.create` again.
await editor.create();
```
If you want to **clear the plugins and configs for the editor** when calling `editor.destroy`, you can pass `true` to `editor.destroy`.
```typescript
await editor.destroy(true);
```
-252
View File
@@ -1,252 +0,0 @@
# Keyboard Shortcuts
Keyboard shortcuts are a crucial part of the editor's user experience. Milkdown provides a flexible system for configuring keyboard shortcuts through presets and plugins.
## Default Shortcuts
---
Milkdown comes with a set of default keyboard shortcuts from both presets and plugins. Here's a comprehensive list of all internal shortcuts:
> #### 💡 Note
>
> `Mod` represents the platform-specific modifier key:
>
> - Windows/Linux: `Ctrl`
> - macOS: `Command`
### Commonmark Preset Shortcuts
#### Headings
| Shortcut | Description |
| -------------------- | ----------------------- |
| `Mod-Alt-1` | Turn block into h1 |
| `Mod-Alt-2` | Turn block into h2 |
| `Mod-Alt-3` | Turn block into h3 |
| `Mod-Alt-4` | Turn block into h4 |
| `Mod-Alt-5` | Turn block into h5 |
| `Mod-Alt-6` | Turn block into h6 |
| `Delete`/`Backspace` | Downgrade heading level |
#### Block Elements
| Shortcut | Description |
| ------------- | ---------------------------- |
| `Mod-Shift-b` | Wrap selection in blockquote |
| `Mod-Shift-8` | Wrap in bullet list |
| `Mod-Shift-7` | Wrap in ordered list |
| `Mod-Shift-c` | Wrap in code block |
| `Shift-Enter` | Insert hard break |
| `Mod-Alt-0` | Wrap in paragraph |
#### Text Formatting
| Shortcut | Description |
| -------- | ------------------ |
| `Mod-b` | Toggle bold |
| `Mod-i` | Toggle italic |
| `Mod-e` | Toggle inline code |
### GFM Preset Shortcuts
#### Text Formatting
| Shortcut | Description |
| ----------- | -------------------- |
| `Mod-Alt-x` | Toggle strikethrough |
#### Tables
| Shortcut | Description |
| ------------------- | -------------------------------- |
| `Mod-]` | Move to next cell |
| `Mod-[` | Move to previous cell |
| `Mod-Enter`/`Enter` | Exit table and break if possible |
## Configuring Shortcuts
---
You can customize keyboard shortcuts by configuring the keymap in the editor setup:
```typescript
import { blockquoteKeymap, commonmark } from "@milkdown/kit/preset/commonmark";
Editor.make()
.config((ctx) => {
ctx.set(blockquoteKeymap.key, {
WrapInBlockquote: "Mod-Shift-b",
// or you may want to bind multiple keys:
WrapInBlockquote: ["Mod-Shift-b", "Mod-b"],
});
})
.use(commonmark);
```
## Defining Keymaps
---
Keymaps in Milkdown are defined using the `$useKeymap` utility. Here's how to define keymaps for different features:
### Heading Keymap Example
```typescript
import { $useKeymap } from "@milkdown/utils";
import { commandsCtx } from "@milkdown/core";
export const headingKeymap = $useKeymap("headingKeymap", {
TurnIntoH1: {
shortcuts: "Mod-Alt-1",
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(wrapInHeadingCommand.key, 1);
},
},
TurnIntoH2: {
shortcuts: "Mod-Alt-2",
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(wrapInHeadingCommand.key, 2);
},
},
// ... more heading levels
DowngradeHeading: {
shortcuts: ["Delete", "Backspace"],
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(downgradeHeadingCommand.key);
},
},
});
```
### Strong (Bold) Keymap Example
```typescript
import { $useKeymap } from "@milkdown/utils";
import { commandsCtx } from "@milkdown/core";
export const strongKeymap = $useKeymap("strongKeymap", {
ToggleBold: {
shortcuts: ["Mod-b"],
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(toggleStrongCommand.key);
},
},
});
```
### Keymap Structure
Each keymap definition follows this structure:
```typescript
$useKeymap('keymapName', {
CommandName: {
shortcuts: string | string[], // Single shortcut or array of shortcuts
priority?: number, // (Optional) Priority of the shortcut
command: (ctx) => () => { // Command to execute
const commands = ctx.get(commandsCtx);
return () => commands.call(commandKey, ...args);
},
},
});
```
## Creating Custom Shortcuts
---
If you need to add custom shortcuts, you can create a keymap plugin:
```typescript
import { $useKeymap } from "@milkdown/utils";
import { commandsCtx } from "@milkdown/core";
const customKeymap = $useKeymap("customKeymap", {
CustomCommand: {
shortcuts: "F1",
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(someCommand.key);
},
},
});
// Usage
Editor.make().use(customKeymap).use(commonmark);
```
### Example: Custom Command with Shortcut
```typescript
import { $command, $useKeymap } from "@milkdown/utils";
import { commandsCtx } from "@milkdown/core";
// Create a custom command
const customCommand = $command("CustomCommand", (ctx) => () => {
return (state, dispatch) => {
// Command implementation
return true;
};
});
// Create a keymap
const customKeymap = $useKeymap("customKeymap", {
CustomCommand: {
shortcuts: ["F1", "Mod-F1"], // Multiple shortcuts
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(customCommand.key);
},
},
});
// Usage
Editor.make().use(customCommand).use(customKeymap);
```
## Shortcut Priority
You can control the order in which shortcuts are handled by specifying a `priority` property. Shortcuts with higher priority values are handled before those with lower values. This is useful if you want your custom shortcut to override or take precedence over other shortcuts that use the same key combination.
When multiple shortcuts are registered for the same key, they are executed in order of priority. If a shortcut command returns `false`, the next shortcut with the same key will be tried. If it returns `true`, no further commands for that key will be run. This allows you to chain or override shortcut behaviors as needed.
- The default priority is **50**.
- Normal priority values should be between **1** and **100**.
- Use higher numbers to ensure your shortcut is registered before others with the same key.
#### Example: Using Priority
```typescript
import { $useKeymap } from "@milkdown/utils";
import { commandsCtx } from "@milkdown/core";
export const customKeymap = $useKeymap("customKeymap", {
CustomBold: {
shortcuts: "Mod-b",
priority: 100, // Highest in the normal range, so this runs first
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => {
// Custom bold logic
return true;
};
},
},
CustomAnotherBold: {
shortcuts: "Mod-b",
priority: 75, // Lower priority, will run only if CustomBold returns false
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => {
// Custom italic logic
return true;
};
},
},
});
```
-278
View File
@@ -1,278 +0,0 @@
# Macros
Macros are helper functions that provide a convenient way to interact with the editor. They take a payload (or nothing) as parameters and return a callback function that takes the `ctx` of milkdown as a parameter. When called with `ctx`, they apply the specified action to the editor.
## Usage
There are two main ways to use macros:
```typescript
import { insert } from "@milkdown/kit/utils";
import { listenerCtx } from "@milkdown/plugin-listener";
// Method 1: Using editor.action()
editor.action(insert("# Hello Macro"));
// Method 2: Using listener
editor.config((ctx) => {
ctx.get(listenerCtx).mounted(insert("# Default Title"));
});
```
## Available Macros
### Content Manipulation
#### `insert`
Inserts content at the current cursor position. The macro accepts two parameters:
- `markdown`: The markdown string to insert
- `inline`: Optional boolean flag (default: false) that determines how the content is inserted
```typescript
import { insert } from "@milkdown/kit/utils";
// Insert as block content (default)
editor.action(insert("# Hello World"));
// Insert as inline content
editor.action(insert("inline text", true));
```
The behavior differs based on the `inline` parameter:
- When `inline` is `false` (default):
- Replaces the current selection with the parsed markdown content
- Maintains the selection's open start/end positions
- Scrolls the view to show the inserted content
- When `inline` is `true`:
- Attempts to insert the content as inline text
- If the content is text-only, replaces the selection with a text node
- Otherwise, replaces the selection with the parsed content
#### `insertPos`
Inserts markdown at a given position. The macro accepts two parameters:
- `markdown`: The markdown string to insert
- `pos`: The position to insert the content at
```typescript
import { insertPos } from "@milkdown/kit/utils";
// Insert "Hello" at the beginning of the document
editor.action(insertPos("Hello", 0));
```
#### `replaceAll`
Replaces all content in the editor. The macro accepts two parameters:
- `markdown`: The markdown string to replace the current content with
- `flush`: Optional boolean flag (default: false) that determines how the replacement is performed
```typescript
import { replaceAll } from "@milkdown/kit/utils";
// Replace content without flushing state
editor.action(replaceAll("# New Content"));
// Replace content and flush editor state
editor.action(replaceAll("# New Content", true));
```
The behavior differs based on the `flush` parameter:
- When `flush` is `false` (default):
- Replaces the entire document content with the new markdown
- Maintains the current editor state
- More efficient for simple content replacements
- When `flush` is `true`:
- Creates a new editor state with the new content
- Reinitializes all plugins
- Useful when you need a completely fresh editor state
#### `replaceRange`
Replaces the content of the given range with a markdown string.
```typescript
import { replaceRange } from "@milkdown/kit/utils";
// Replace content from position 0 to 5 with "Hello"
editor.action(replaceRange("Hello", { from: 0, to: 5 }));
```
### Content Retrieval
#### `getMarkdown`
Gets the current content as markdown. If a range is provided, it will return the markdown for that range; otherwise, it will return the markdown for the entire document.
```typescript
import { getMarkdown } from "@milkdown/kit/utils";
// Get markdown for the entire document
const markdown = editor.action(getMarkdown());
// Get markdown for a specific range
const selectionMarkdown = editor.action(getMarkdown({ from: 0, to: 5 }));
```
#### `getHTML`
Gets the current content as HTML.
```typescript
import { getHTML } from "@milkdown/kit/utils";
const html = editor.action(getHTML());
```
### Editor State
#### `forceUpdate`
Forces the editor to update its state.
```typescript
import { forceUpdate } from "@milkdown/kit/utils";
editor.action(forceUpdate());
```
#### `setAttr`
Sets attributes for a node at a specific position. The macro accepts two parameters:
- `pos`: The position of the node to update
- `update`: A function that takes the previous attributes and returns the new attributes
```typescript
import { setAttr } from "@milkdown/kit/utils";
// Update node attributes at position 10
editor.action(
setAttr(10, (prevAttrs) => ({
...prevAttrs,
class: "custom-class",
})),
);
// Example: Update heading level
editor.action(
setAttr(10, (prevAttrs) => ({
...prevAttrs,
level: 2,
})),
);
```
The macro:
- Takes a specific position in the document
- Retrieves the node at that position
- Applies the update function to modify the node's attributes
- Dispatches the changes to update the editor state
Note: The position must be valid and contain a node, otherwise the operation will be ignored.
### Navigation
#### `outline`
Gets the outline of the document.
```typescript
import { outline } from "@milkdown/kit/utils";
const docOutline = editor.action(outline());
```
### Command Execution
#### `callCommand`
Calls a registered command with optional payload. The macro has two overloads:
Examples:
```typescript
import { callCommand } from "@milkdown/kit/utils";
import { wrapInHeadingCommand } from "@milkdown/plugin-heading";
// Using command key
editor.action(callCommand(wrapInHeadingCommand.key, 1));
// With complex payload
editor.action(
callCommand("CustomCommand", {
type: "heading",
level: 1,
content: "New Heading",
}),
);
```
The macro:
- Takes a command key
- Optionally accepts a payload parameter
- Returns a boolean indicating whether the command was successful
Note: The command must be registered in the editor's command context before it can be called.
### Utility Macros
#### `markdownToSlice`
Converts a markdown string to a [slice](https://prosemirror.net/docs/ref/#model.Slice). This is useful when you need to manipulate the content before inserting it into the editor.
```typescript
import { markdownToSlice } from "@milkdown/kit/utils";
const slice = editor.action(markdownToSlice("# Hello Slice"));
```
## Examples
### Adding Content
```typescript
import { insert } from "@milkdown/kit/utils";
import { listenerCtx } from "@milkdown/plugin-listener";
editor.config((ctx) => {
ctx.get(listenerCtx).mounted(insert("# Welcome\nStart editing..."));
});
```
### Saving Content
```typescript
import { getMarkdown } from "@milkdown/kit/utils";
editor.config((ctx) => {
ctx.get(listenerCtx).updated(() => {
const content = getMarkdown()(ctx);
localStorage.setItem("editor-content", content);
});
});
```
### Custom Command with Macro
```typescript
import { callCommand } from "@milkdown/kit/utils";
editor.action(
callCommand("customCommand", {
type: "heading",
level: 1,
content: "New Heading",
}),
);
```
For more details about each macro's parameters and return types, check the [API Reference](/docs/api/utils#macros).
-38
View File
@@ -1,38 +0,0 @@
# Prosemirror API
Milkdown is built on top of prosemirror. Which means you can use the entire prosemirror API in Milkdown.
To access the prosemirror API, you can use the `@milkdown/prose` package. It re-exports all of the prosemirror API.
Using this package you can make sure that you are using the same version of prosemirror as Milkdown.
## Installation
To access a certain API in the `prosemirror-x` package, you need to import them from `@milkdown/kit/prose/x`.
For example:
```ts
// Originally in prosemirror-state
import { EditorState } from "@milkdown/kit/prose/state";
// Originally in prosemirror-view
import { EditorView } from "@milkdown/kit/prose/view";
```
## List of packages
The following is a list of all the re-exported prosemirror API.
- `@milkdown/kit/prose/changeset`
- `@milkdown/kit/prose/commands`
- `@milkdown/kit/prose/dropcursor`
- `@milkdown/kit/prose/gapcursor`
- `@milkdown/kit/prose/history`
- `@milkdown/kit/prose/inputrules`
- `@milkdown/kit/prose/keymap`
- `@milkdown/kit/prose/model`
- `@milkdown/kit/prose/schema-list`
- `@milkdown/kit/prose/state`
- `@milkdown/kit/prose/transform`
- `@milkdown/kit/prose/view`
- `@milkdown/kit/prose/tables`
You can find the documentation of the prosemirror API [here](https://prosemirror.net/docs/ref/).
-215
View File
@@ -1,215 +0,0 @@
# Styling Guide
Milkdown is a headless editor, which means it doesn't come with any default styles. This gives you complete control over the appearance of your editor. You can either use existing themes or create your own custom styling solution.
# Styling Crepe Theme
---
Crepe is a collection of themes for Milkdown that provides both light and dark variants. The theme structure is organized as follows:
```
theme/
├── common/ # Shared styles and utilities
├── crepe/ # Light theme variant
├── crepe-dark/ # Dark theme variant
├── frame/ # Frame theme (light)
├── frame-dark/ # Frame theme (dark)
├── nord/ # Nord theme (light)
└── nord-dark/ # Nord theme (dark)
```
## Using Crepe Theme
To use the Crepe theme in your project:
```ts
// Import base styles first
import "@milkdown/crepe/theme/common/style.css";
// Choose the theme you want to use
import "@milkdown/crepe/theme/crepe.css";
```
## Theme Variables
Crepe theme uses CSS variables for consistent styling. Here are all the available variables:
### Colors
```css
.milkdown {
/* Background Colors */
--crepe-color-background: #fffdfb; /* Main background color */
--crepe-color-surface: #fff8f4; /* Surface color for cards/panels */
--crepe-color-surface-low: #fff1e5; /* Lower surface color for depth */
/* Text Colors */
--crepe-color-on-background: #1f1b16; /* Text color on background */
--crepe-color-on-surface: #201b13; /* Text color on surface */
--crepe-color-on-surface-variant: #4f4539; /* Secondary text color */
/* Accent Colors */
--crepe-color-primary: #805610; /* Primary brand color */
--crepe-color-secondary: #fbdebc; /* Secondary accent color */
--crepe-color-on-secondary: #271904; /* Text color on secondary */
/* UI Colors */
--crepe-color-outline: #817567; /* Border/outline color */
--crepe-color-inverse: #362f27; /* Inverse color for contrast */
--crepe-color-on-inverse: #fcefe2; /* Text color on inverse */
--crepe-color-inline-code: #ba1a1a; /* Inline code color */
--crepe-color-error: #ba1a1a; /* Error state color */
/* Interactive Colors */
--crepe-color-hover: #f9ecdf; /* Hover state color */
--crepe-color-selected: #ede0d4; /* Selected state color */
--crepe-color-inline-area: #e4d8cc; /* Inline editing area color */
}
```
### Typography
```css
.milkdown {
/* Font Families */
--crepe-font-title: Georgia, Cambria, "Times New Roman", Times, serif;
--crepe-font-default: "Open Sans", Arial, Helvetica, sans-serif;
--crepe-font-code:
Fira Code, Menlo, Monaco, "Courier New", Courier, monospace;
}
```
### Shadows
```css
.milkdown {
/* Small Shadow */
--crepe-shadow-1:
0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
/* Large Shadow */
--crepe-shadow-2:
0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
}
```
## Customizing Crepe Theme
You can customize the Crepe theme by overriding its variables:
```css
/* custom-overrides.css */
.crepe .milkdown {
/* Override colors */
--crepe-color-primary: #your-primary-color;
--crepe-color-background: #your-background-color;
/* Override typography */
--crepe-font-default: "Your Font", sans-serif;
/* Override shadows */
--crepe-shadow-1: your-shadow-value;
}
```
# Styling Milkdown
---
## Basic Styling
The editor is rendered within a container that has the class `.milkdown`, and the editable content area is wrapped in a container with the class `.editor`. You can use these classes to scope your styles:
```css
/* Basic styling example */
.milkdown .editor {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.milkdown .editor p {
margin: 1rem 0;
line-height: 1.6;
}
```
## Node and Mark Classes
Milkdown provides default class names for each node and mark. Here are some common examples:
```css
/* Paragraph styling */
.milkdown .editor .paragraph {
margin: 1rem 0;
}
/* Heading styling */
.milkdown .editor .heading {
font-weight: 600;
margin: 1.5rem 0 1rem;
}
/* List styling */
.milkdown .editor .bullet-list {
padding-left: 1.5rem;
}
.milkdown .editor .ordered-list {
padding-left: 1.5rem;
}
```
## Custom Attributes
You can add custom attributes to nodes and marks, which is particularly useful when working with CSS frameworks like Tailwind CSS.
```typescript
import { Editor, editorViewOptionsCtx } from "@milkdown/kit/core";
import {
commonmark,
headingAttr,
paragraphAttr,
} from "@milkdown/kit/preset/commonmark";
Editor.make()
.config((ctx) => {
// Add attributes to the editor container
ctx.update(editorViewOptionsCtx, (prev) => ({
...prev,
attributes: {
class: "milkdown-editor mx-auto outline-hidden",
spellcheck: "false",
},
}));
// Add attributes to nodes and marks
ctx.set(headingAttr.key, (node) => {
const level = node.attrs.level;
return {
class: `heading-${level} font-bold`,
"data-level": level,
};
});
ctx.set(paragraphAttr.key, () => ({
class: "text-base leading-relaxed",
}));
})
.use(commonmark);
```
# Best Practices
---
1. **Use CSS Variables**: Define your theme's colors and spacing using CSS variables for easy customization.
2. **Responsive Design**: Ensure your editor styles work well on different screen sizes.
3. **Dark Mode Support**: Consider adding dark mode support using CSS variables and media queries.
4. **Accessibility**: Maintain good contrast ratios and readable font sizes.
5. **Performance**: Keep your CSS selectors specific and avoid overly complex rules.
For more examples and inspiration, check out:
- [@milkdown/theme-nord](https://github.com/Milkdown/milkdown/tree/main/packages/theme-nord)
- [@milkdown/crepe/theme](https://github.com/Milkdown/milkdown/tree/main/packages/crepe/src/theme)
-234
View File
@@ -1,234 +0,0 @@
# Using Crepe Editor
Crepe is a powerful, feature-rich Markdown editor built on top of Milkdown. It provides a complete editing experience with a beautiful UI and extensive customization options.
## Why Choose Crepe?
---
- 🚀 **Ready to Use**: Works out of the box with sensible defaults
- 🎨 **Beautiful UI**: Modern design with multiple theme options
- 🔧 **Highly Customizable**: Extensive configuration options
- 📦 **Feature Complete**: Includes all essential Markdown editing features
- 🛠️ **Extensible**: Built on Milkdown's plugin system
## Quick Start
---
### Installation
```bash
# Using npm
npm install @milkdown/crepe
# Using yarn
yarn add @milkdown/crepe
# Using pnpm
pnpm add @milkdown/crepe
```
### Basic Usage
```typescript
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
// Choose your preferred theme
// Create editor instance
const crepe = new Crepe({
root: document.getElementById("app"),
defaultValue: "# Hello, Crepe!\n\nStart writing your markdown...",
});
// Initialize the editor
await crepe.create();
// Clean up when done
crepe.destroy();
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/editor-crepe"}
## Themes
---
Crepe comes with several beautiful themes out of the box:
### Light Themes
- `frame` - Modern frame-based design
- `classic` - Traditional editor look
- `nord` - Clean, minimal Nord color scheme
### Dark Themes
- `frame-dark` - Dark version of frame theme
- `classic-dark` - Dark version of classic theme
- `nord-dark` - Dark version of nord theme
To use a theme:
```typescript
// Import base styles first
import "@milkdown/crepe/theme/common/style.css";
// Then import your chosen theme
import "@milkdown/crepe/theme/frame.css";
```
### Custom Themes
You can create your own theme by extending the base styles. Check out the [existing themes](https://github.com/Milkdown/milkdown/tree/main/packages/crepe/src/theme) for reference.
## Features
---
Crepe includes a comprehensive set of features that can be enabled or disabled as needed.
### Feature Configuration
> **Note**: For any configuration that ends with `Icon` (like `boldIcon`, `linkIcon`, etc.), you can use a HTML string or a simply string. This applies to all icon configurations throughout Crepe's features.
```typescript
const crepe = new Crepe({
features: {
// Disable specific features
[Crepe.Feature.CodeMirror]: false,
[Crepe.Feature.Table]: false,
},
featureConfigs: {
// Configure feature behavior
[Crepe.Feature.LinkTooltip]: {
inputPlaceholder: "Enter URL...",
},
},
});
```
### Available Features
#### 1. Code Editor (`CodeMirror`)
Syntax highlighting and editing for code blocks with language support, theme customization, and preview capabilities.
#### 2. List Management (`ListItem`)
Support for bullet lists, ordered lists, and todo lists with customizable icons and formatting.
#### 3. Link Management (`LinkTooltip`)
Enhanced link editing and preview with customizable tooltips, edit/remove actions, and copy functionality.
#### 4. Image Handling (`ImageBlock`)
Image upload and management with resizing, captions, and support for both inline and block images.
#### 5. Block Editing (`BlockEdit`)
Drag-and-drop block management and slash commands for quick content insertion and organization.
#### 6. Table Support (`Table`)
Full-featured table editing with row/column management, alignment options, and drag-and-drop functionality.
#### 7. Toolbar (`Toolbar`)
Formatting toolbar for selected text with customizable icons and actions.
#### 8. Cursor (`Cursor`)
Enhanced cursor experience with drop cursor and gap cursor for better content placement.
#### 9. Placeholder (`Placeholder`)
Document or block level placeholders to guide users when content is empty.
#### 10. Latex (`Latex`)
Mathematical formula support with both inline and block math rendering using KaTeX.
For detailed configuration options of each feature, please refer to the [API documentation](/docs/api/crepe).
## Editor Instance Methods
---
#### `crepe.editor`
Access the underlying Milkdown editor instance.
```typescript
const editor = crepe.editor;
editor.use(customPlugin);
editor.action(insert("Hello"));
```
#### `crepe.create()`
Initialize the editor.
```typescript
await crepe.create();
```
#### `crepe.destroy()`
Clean up the editor instance.
```typescript
crepe.destroy();
```
#### `crepe.setReadonly(value: boolean)`
Toggle readonly mode.
```typescript
crepe.setReadonly(true); // Make editor read-only
crepe.setReadonly(false); // Make editor editable
```
#### `crepe.on`
Add event listeners.
```typescript
crepe.on((listener) => {
listener.markdownUpdated((markdown) => {
console.log("Markdown updated:", markdown);
});
listener.updated((doc) => {
console.log("Document updated");
});
listener.focus(() => {
console.log("Editor focused");
});
listener.blur(() => {
console.log("Editor blurred");
});
});
```
#### `crepe.getMarkdown()`
Get current markdown content.
```typescript
const markdown = crepe.getMarkdown();
```
## Next Steps
---
- Learn about [Milkdown's architecture](/docs/guide/architecture-overview)
- Explore [available plugins](/docs/plugin/using-plugins)
- Read the [API reference](/docs/api/crepe)
-37
View File
@@ -1,37 +0,0 @@
# Using @milkdown/kit
Milkdown provides a set of utilities to help you build your editor.
These utilities are re-exported from the `@milkdown/kit` package.
Thus, you don't need to install the common dependencies manually like `@milkdown/prose`, `@milkdown/core` or `@milkdown/preset-common` in your project.
## What's included
`@milkdown/kit` re-exports the following packages:
| Package | Import path | Scope |
| ---------------------------------------------------------- | ----------------------------------------- | --------- |
| [@milkdown/core](/docs/api/core) | `@milkdown/kit/core` | Framework |
| [@milkdown/ctx](/docs/api/ctx) | `@milkdown/kit/ctx` | Framework |
| [@milkdown/prose](/docs/guide/prosemirror-api) | `@milkdown/kit/prose` | Framework |
| [@milkdown/prose/\*](/docs/guide/prosemirror-api) | `@milkdown/kit/prose/*` | Framework |
| [@milkdown/transformer](/docs/api/transformer) | `@milkdown/kit/transformer` | Framework |
| [@milkdown/utils](/docs/api/utils) | `@milkdown/kit/utils` | Framework |
| [@milkdown/preset-commonmark](/docs/api/preset-commonmark) | `@milkdown/kit/preset/commonmark` | Preset |
| [@milkdown/preset-gfm](/docs/api/preset-gfm) | `@milkdown/kit/preset/gfm` | Preset |
| [@milkdown/plugin-block](/docs/api/plugin-block) | `@milkdown/kit/plugin/block` | Plugin |
| [@milkdown/plugin-clipboard](/docs/api/plugin-clipboard) | `@milkdown/kit/plugin/clipboard` | Plugin |
| [@milkdown/plugin-cursor](/docs/api/plugin-cursor) | `@milkdown/kit/plugin/cursor` | Plugin |
| [@milkdown/plugin-history](/docs/api/plugin-history) | `@milkdown/kit/plugin/history` | Plugin |
| [@milkdown/plugin-indent](/docs/api/plugin-indent) | `@milkdown/kit/plugin/indent` | Plugin |
| [@milkdown/plugin-listener](/docs/api/plugin-listener) | `@milkdown/kit/plugin/listener` | Plugin |
| [@milkdown/plugin-slash](/docs/api/plugin-slash) | `@milkdown/kit/plugin/slash` | Plugin |
| [@milkdown/plugin-tooltip](/docs/api/plugin-tooltip) | `@milkdown/kit/plugin/tooltip` | Plugin |
| [@milkdown/plugin-trailing](/docs/api/plugin-trailing) | `@milkdown/kit/plugin/trailing` | Plugin |
| [@milkdown/plugin-upload](/docs/api/plugin-upload) | `@milkdown/kit/plugin/upload` | Plugin |
| @milkdown/component | `@milkdown/kit/component` | Component |
| @milkdown/component/code-block | `@milkdown/kit/component/code-block` | Component |
| @milkdown/component/image-block | `@milkdown/kit/component/image-block` | Component |
| @milkdown/component/image-inline | `@milkdown/kit/component/image-inline` | Component |
| @milkdown/component/link-tooltip | `@milkdown/kit/component/link-tooltip` | Component |
| @milkdown/component/list-item-block | `@milkdown/kit/component/list-item-block` | Component |
| @milkdown/component/table-block | `@milkdown/kit/component/table-block` | Component |
-30
View File
@@ -1,30 +0,0 @@
# Why Milkdown
There are different kinds of markdown editors, such as [Typora](https://typora.io/), [tui](https://github.com/nhn/tui.editor) and [Bear](https://bear.app/).
They work pretty well for writing notes in markdown on different platforms. So why bother making Milkdown?
Milkdown aims to provide an **open source solution** for developers to make their editors more powerful, and attractive, it also ensures it runs everywhere.
---
## Open Source & Easy to Integrate
Different from industrial apps such as [Notion](https://notion.so) and [Typora](https://typora.io/),
Milkdown is open source and fully free. You can integrate it everywhere legally.
> If you like milkdown, please consider to fund me in order to help with the maintenance.
## Plugin Driven
Milkdown treats every feature as a plugin.
With this pattern, developers can choose what they need in an editor instead of bundling all features even they won't need.
Developers can extend their plugins to satisfy their habits such as defining a vim keymap via a custom plugin.
## Reliable
Milkdown is powered by [Prosemirror](https://prosemirror.net/) and [Remark](https://github.com/remarkjs/remark), which has a large community and stands the test of the industry.
What's more, plugins from the prosemirror and remark community can be easily reused in order to build a Milkdown plugin.
## Themable & Hackable
Themes and plugins for Milkdown can be shared and installed using npm packages. Milkdown is a headless component, which means you can fully control its style.
-90
View File
@@ -1,90 +0,0 @@
# Milkdown
👋 Welcome to Milkdown. We are so glad to see you here!
💭 You may wonder, what is Milkdown? Please write something here.
> ⚠️ **Not the right side!**
>
> Please try something on the left side.
![1.00](/polar.jpeg "Hello by a polar bear")
You're seeing this editor called **🥞Crepe**, which is an editor built on top of Milkdown.
If you want to install this editor, you can run `npm install @milkdown/crepe`. Then you can use it like this:
```js
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
// We have some themes for you to choose, ex.
import "@milkdown/crepe/theme/frame.css";
// Or you can create your own theme
import "./your-theme.css";
const crepe = new Crepe({
root: "#app",
defaultValue: "# Hello, Milkdown!",
});
crepe.create().then(() => {
console.log("Milkdown is ready!");
});
// Before unmount
crepe.destroy();
```
---
## Structure
> 🍼 [Milkdown][repo] is a WYSIWYG markdown editor framework.
>
> Which means you can build your own markdown editor with Milkdown.
In the real world, a typical milkdown editor is built on top of 3 layers:
- [x] 🥛 Core: The core of Milkdown, which provides the plugin loading system with the editor concepts.
- [x] 🧇 Plugins: A set of plugins that can be used to extend the functionalities of the editor.
- [x] 🍮 Components: Some headless components that can be used to build your own editor.
At the start, you may find it hard to understand all these concepts.
But don't worry, we have this `@milkdown/crepe` editor for you to get started quickly.
---
## You can do more with Milkdown
In Milkdown, you can extend the editor in many ways:
| Feature | Description | Example |
| ------------ | ---------------------------------------------------- | ------------------------- |
| 🎨 Theme | Create your own theme with CSS | Nord, Dracula |
| 🧩 Plugin | Create your own plugin to extend the editor | Search, Collab |
| 📦 Component | Create your own component to build your own editor | Slash Menu, Toolbar |
| 📚 Syntax | Create your own syntax to extend the markdown parser | Image with Caption, LaTex |
We have provided a lot of plugins and components, with an out-of-the-box crepe editor for you to use and learn.
---
## Open Source
- Milkdown is an open-source project under the MIT license.
- Everyone is welcome to contribute to the project, and you can use it in your own project for free.
- Please let me know what you are building with Milkdown, I would be so glad to see that!
Maintaining Milkdown is a lot of work, and we are working on it in our spare time.
If you like Milkdown, please consider supporting us by [sponsoring][sponsor] the project.
We'll be so grateful for your support.
## Who built Milkdown?
Milkdown is built by [Mirone][mirone] and designed by [Meo][meo].
[repo]: https://github.com/Milkdown/milkdown
[mirone]: https://github.com/Saul-Mirone
[meo]: https://meo.cool
[sponsor]: https://github.com/sponsors/Saul-Mirone
@@ -1,85 +0,0 @@
# Composable Plugins
In the previous section, we showed you how to create a plugin from scratch. Luckily, you don't need to do that in most cases. Milkdown provides a lot of helpers in [@milkdown/utils](/docs/api/utils) to make it easier to create plugins. The **composable** here means that you can use the plugin in other plugins. For example, you can use a command plugin in a keymap plugin. This is a very common pattern in Milkdown.
I'll show you some examples of how to use composable plugins. But I won't go into detail about the options and the usage of each plugin. You can find the details in the [API reference](/docs/api/utils#composable).
## Schema
The schema plugin is the most important plugin in Milkdown. It defines the structure of the document. A schema plugin in milkdown is a super set of the [node schema spec](https://prosemirror.net/docs/ref/#model.NodeSpec) or [mark schema spec](https://prosemirror.net/docs/ref/#model.MarkSpec) in ProseMirror.
Let's create a simple blockquote node plugin as an example:
```typescript
import { $node } from "@milkdown/kit/utils";
const blockquote = $node("blockquote", () => ({
content: "block+",
group: "block",
defining: true,
parseDOM: [{ tag: "blockquote" }],
toDOM: (node) => ["blockquote", ctx.get(blockquoteAttr.key)(node), 0],
parseMarkdown: {
match: ({ type }) => type === "blockquote",
runner: (state, node, type) => {
state.openNode(type).next(node.children).closeNode();
},
},
toMarkdown: {
match: (node) => node.type.name === "blockquote",
runner: (state, node) => {
state.openNode("blockquote").next(node.content).closeNode();
},
},
}));
```
## Input Rule
Since we have a blockquote node, we can create an input rule plugin to make it easier to create a blockquote node.
We expect that when we type `> ` at the beginning of a line, the blockquote node will be created.
```typescript
import { wrappingInputRule } from "@milkdown/kit/prose/inputrules";
import { $inputRule } from "@milkdown/kit/utils";
export const wrapInBlockquoteInputRule = $inputRule(() =>
wrappingInputRule(/^\s*>\s$/, blockquoteSchema.type()),
);
```
## Command
We can also create a command plugin to create a blockquote node.
The command is useful when we want to create a button to create a blockquote node.
```typescript
import { wrapIn } from "@milkdown/kit/prose/commands";
import { $command } from "@milkdown/kit/utils";
export const wrapInBlockquoteCommand = $command(
"WrapInBlockquote",
() => () => wrapIn(blockquoteSchema.type()),
);
```
## Shortcut
We can also create a shortcut plugin for blockquote.
Here we use `Ctrl + Shift + B` as the shortcut. When we press this shortcut, the blockquote node will be created.
And we can also use the command we created in the previous section.
```typescript
import { commandsCtx } from "@milkdown/kit/core";
import { $useKeymap } from "@milkdown/kit/utils";
export const blockquoteKeymap = $useKeymap("blockquoteKeymap", {
WrapInBlockquote: {
shortcuts: "Mod-Shift-b",
command: (ctx) => {
const commands = ctx.get(commandsCtx);
return () => commands.call(wrapInBlockquoteCommand.key);
},
},
});
```
@@ -1,125 +0,0 @@
# Example: Block Plugin
The **block plugin** adds a positional hook next to every top-level node (paragraphs, headings, lists, etc.).
It is the foundation for features such as drag handles, quick-insert buttons or block toolbars.
In Milkdown this functionality lives in `@milkdown/plugin-block` and consistent with tooltip & slash consists of:
- a **BlockProvider** that deals with DOM positioning/lifecycle
- a **blockFactory** _implemented internally_ exposed as two ctx slices: `blockSpec`, `blockPlugin`
This guide covers:
- Understanding the provider/service architecture.
- Writing a **vanilla TypeScript** drag handle that lets you reorder blocks.
- Mounting custom UIs in **React** and **Vue**.
- Studying the production-ready _Block Handle_ feature inside Crepe.
---
## 1. Anatomy of a Block Plugin
Unlike tooltip/slash, `@milkdown/plugin-block` ships its factory slices directly:
```ts
import { blockSpec, blockPlugin } from "@milkdown/plugin-block";
```
You normally interact with **BlockProvider** which talks to an internal _BlockService_: the service listens to mouse / drag events, figures out which node is **active** and sends `show` / `hide` messages to the provider.
Your job is to decide how to render a UI for that active node.
Key APIs:
- `new BlockProvider({ ctx, content, ... })` similar to Tooltip/Slash.
- `provider.active` info about the currently focused block (`node`, `pos`, `el`).
- Optional callbacks: `getOffset`, `getPlacement`, `getPosition` for fine-grained positioning.
---
## 2. Minimal Vanilla Drag Handle
Below we build a small **drag handle** that appears on hover and lets you drag-n-drop any block.
```ts
import { block, blockPlugin } from "@milkdown/plugin-block";
import { BlockProvider } from "@milkdown/plugin-block/block-provider"; // path depending on bundler
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
// 1️⃣ Create DOM element for the handle
const handle = document.createElement("div");
handle.className = "drag-handle";
handle.innerHTML = "≡";
handle.style.cssText = `
width:20px;height:20px;display:flex;align-items:center;justify-content:center;
cursor:grab;border-radius:4px;background:#f2f3f5;color:#555;user-select:none;
`;
// 2️⃣ Build provider show only when mouse is over a block
const provider = (ctx: Ctx) => {
const provider = new BlockProvider({
ctx,
content: handle,
getOffset: () => 8,
});
return {
update: provider.update,
destroy: provider.destroy,
};
};
// 3️⃣ Wire provider to Milkdown
const blockConfig = (ctx: Ctx) => {
ctx.set(blockSpec.key, {
view: provider(ctx),
});
};
Editor.make().config(blockConfig).use(commonmark).use(block).create();
```
Drag & Drop:
The HTML element has `cursor:grab`. The internal `BlockService` automatically sets `draggable` and wires ProseMirror's drag-events so you can reorder blocks without extra code 👉 nice!
---
## 3. Framework Examples
### React
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-block"}
The React demo renders a `<BlockHandle/>` component, keeps drag state in hooks and feeds the root element to `BlockProvider`.
### Vue
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-block"}
Vue's `<BlockHandle>` uses `Teleport` and reactive refs exactly like the tooltip/slash examples.
---
## 4. Real-world Feature Crepe Block Handle
Crepe brings all the pieces together to create a **block edit** experience that combines a drag handle **and** a plus-button to open the slash menu:
```text
packages/crepe/src/feature/block-edit/handle/
```
Things worth exploring:
1. **Dynamic placement** via `getPlacement` (centred vs top-aligned depending on node height).
2. Filtering nodes with `blockConfig.filterNodes` so handles do not appear inside tables / math / blockquotes.
3. Programmatically showing the _slash menu_ after pressing the "+" button.
---
## 5. Summary & Next Steps
`@milkdown/plugin-block` is the Swiss-army knife for any block-level UI: drag handles, add-buttons, side toolbars…
Combine it with tooltip/slash to build sophisticated editors.
Hack on the examples, tweak positioning callbacks, and ship your own block goodies 🚀.
@@ -1,159 +0,0 @@
# Example: Iframe Plugin
This guide demonstrates how to create a custom iframe syntax plugin for Milkdown. This plugin allows you to embed iframes directly in your markdown content using a simple directive syntax.
## Overview
---
The iframe plugin enables you to embed external web content using the following syntax:
```markdown
::iframe{src="https://example.com"}
```
This will render as an embedded iframe in your document.
## Implementation Steps
---
To create a custom syntax plugin in Milkdown, we need to implement five key components:
1. **Remark Plugin**: Parse the custom syntax
2. **Schema Definition**: Define the node structure
3. **Parser**: Convert markdown to ProseMirror nodes
4. **Serializer**: Convert ProseMirror nodes back to markdown
5. **Input Rules**: Handle user input
Let's implement each component:
## 1. Remark Plugin
---
First, we use the `remark-directive` plugin to support our custom syntax. This plugin allows us to define custom directives in markdown.
```typescript
import directive from "remark-directive";
import { $remark } from "@milkdown/kit/utils";
const remarkDirective = $remark("remarkDirective", () => directive);
```
## 2. Schema Definition
---
Next, we define the schema for our iframe node. The schema specifies how the node behaves and appears in the editor.
```typescript
import { $node } from "@milkdown/kit/utils";
import { Node } from "@milkdown/kit/prose/model";
const iframeNode = $node("iframe", () => ({
group: "block", // Block-level node
atom: true, // Cannot be split
isolating: true, // Cannot be merged with adjacent nodes
marks: "", // No marks allowed
attrs: {
src: { default: null }, // URL attribute
},
parseDOM: [
{
tag: "iframe",
getAttrs: (dom) => ({
src: (dom as HTMLElement).getAttribute("src"),
}),
},
],
toDOM: (node: Node) => [
"iframe",
{ ...node.attrs, contenteditable: false }, // Prevent editing iframe content
0,
],
}));
```
## 3. Parser
---
The parser converts our markdown syntax into ProseMirror nodes. It looks for the `leafDirective` type with the name "iframe".
```typescript
parseMarkdown: {
match: (node) => node.type === 'leafDirective' && node.name === 'iframe',
runner: (state, node, type) => {
state.addNode(type, { src: (node.attributes as { src: string }).src });
},
},
```
## 4. Serializer
---
The serializer converts ProseMirror nodes back to markdown format.
```typescript
toMarkdown: {
match: (node) => node.type.name === 'iframe',
runner: (state, node) => {
state.addNode('leafDirective', undefined, undefined, {
name: 'iframe',
attributes: { src: node.attrs.src },
});
},
},
```
## 5. Input Rules
---
Input rules handle user typing and convert the syntax into an iframe node.
```typescript
import { InputRule } from "@milkdown/kit/prose";
import { $inputRule } from "@milkdown/kit/utils";
const iframeInputRule = $inputRule(
() =>
new InputRule(
/::iframe\{src\="(?<src>[^"]+)?"?\}/,
(state, match, start, end) => {
const [okay, src = ""] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(start - 1, end, iframeNode.type().create({ src }));
}
return tr;
},
),
);
```
## Usage
---
To use the iframe plugin, add it to your Milkdown editor configuration:
```typescript
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
Editor.make()
.use([remarkDirective, iframeNode, iframeInputRule])
.use(commonmark)
.create();
```
## Example
---
Here's a complete example of the iframe plugin in action:
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-iframe-syntax"}
@@ -1,189 +0,0 @@
# Example: Marker Plugin
This guide demonstrates how to create a custom marker syntax plugin for Milkdown. This plugin allows you to mark text with custom colors using a simple markdown syntax.
## Overview
---
The marker plugin enables you to mark text using the following syntax:
```markdown
==marked text==
=={#EE4B2B}marked text with color==
```
This will render as marked text in your document, with the option to specify custom colors.
## Implementation Steps
---
To create a custom marker syntax plugin in Milkdown, we need to implement several components:
1. **Remark Plugin**: Parse the custom syntax
2. **Schema Definition**: Define the mark structure
3. **Parser**: Convert markdown to ProseMirror marks
4. **Serializer**: Convert ProseMirror marks back to markdown
5. **Input Rules**: Handle user input
6. **Color Picker**: Add UI for color selection
Let's implement each component:
## 1. Remark Plugin
---
First, we create a remark plugin to handle our custom marker syntax:
> ⚠️ The real implementation is more complex, but we simplify it for the sake of the example.
> Under the hood, you'll need to write a [micromark extension](https://github.com/micromark/micromark) to make it works correctly.
```typescript
import { $remark } from "@milkdown/kit/utils";
const remarkMarkColor = () => {
return (tree: any) => {
visit(tree, "text", (node: any, index: number, parent: any) => {
const match = node.value.match(/==(?:{#([^}]+)})?([^=]+)==/);
if (match) {
const [_, color, text] = match;
const mark = {
type: "mark",
data: { color },
children: [{ type: "text", value: text }],
};
parent.children.splice(index, 1, mark);
}
});
};
};
const milkdownMarkColorPlugin = $remark("markColor", () => remarkMarkColor);
```
## 2. Schema Definition
---
Next, we define the schema for our marker:
```typescript
import { $markSchema } from "@milkdown/kit/utils";
import { Mark } from "mdast";
export const DEFAULT_COLOR = "#ffff00";
export const markSchema = $markSchema("mark", () => ({
attrs: {
color: {
default: DEFAULT_COLOR,
validate: "string",
},
},
parseDOM: [
{
tag: "mark",
getAttrs: (node: HTMLElement) => ({
color: node.style.backgroundColor,
}),
},
],
toDOM: (mark) => ["mark", { style: `background-color: ${mark.attrs.color}` }],
parseMarkdown: {
match: (node) => node.type === "mark",
runner: (state, node, markType) => {
const color = (node as Mark).data?.color;
state.openMark(markType, { color });
state.next(node.children);
state.closeMark(markType);
},
},
toMarkdown: {
match: (node) => node.type.name === "mark",
runner: (state, mark) => {
let color = mark.attrs.color;
if (color?.toLowerCase() === DEFAULT_COLOR.toLowerCase()) {
color = undefined;
}
state.withMark(mark, "mark", undefined, {
data: { color },
});
},
},
}));
```
## 3. Input Rules
---
We add input rules to handle user typing:
```typescript
import { $inputRule } from "@milkdown/kit/utils";
import { InputRule } from "@milkdown/kit/prose";
const markInputRule = $inputRule(
() =>
new InputRule(/==(?:{#([^}]+)})?([^=]+)==/, (state, match, start, end) => {
const [okay, color, text] = match;
const { tr } = state;
if (okay) {
tr.addMark(
start,
end,
markSchema.type().create({ color: color || DEFAULT_COLOR }),
);
}
return tr;
}),
);
```
## 4. Color Picker Tooltip
---
To enhance the user experience, we add a color picker tooltip:
```typescript
export const colorPickerTooltip = tooltipFactory("color-picker");
class TooltipPluginView {
// ... implementation
}
export const colorPickerTooltipConfig = (ctx: Ctx) => {
ctx.set(colorPickerTooltip.key, {
view: () => new TooltipPluginView(ctx),
});
};
```
## Usage
---
To use the marker plugin, add it to your Milkdown editor configuration:
```typescript
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
Editor.make()
.use(milkdownMarkColorPlugin)
.use(markSchema)
.use(markInputRule)
.use(colorPickerTooltip)
.use(commonmark)
.create();
```
## Example
---
Here's a complete example of the marker plugin in action:
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-highlight-syntax"}
@@ -1,137 +0,0 @@
# Example: Slash Plugin
After reading the tooltip guide you already know how Milkdown separates **positioning logic** (provider) from **editor wiring** (ctx slices produced by a factory).
The `@milkdown/plugin-slash` package applies exactly the same idea but focuses on _command palettes_ triggered by a character familiar to `/` menus in modern editors.
This document shows you how to:
- Understand what the slash plugin gives you out-of-the-box.
- Build a **vanilla TypeScript** implementation of a basic `/` menu.
- Use the slash provider with **React** and **Vue**.
- Explore a full-blown menu feature that ships inside Milkdown's Crepe UI.
---
## 1. Anatomy of a Slash Plugin
`@milkdown/plugin-slash` exports two utilities:
1. **`SlashProvider`** Measures the caret position and manages show / hide of your menu.
2. **`slashFactory(id)`** Generates a ctx slice & ProseMirror plugin pair that plugs the provider into the editor.
```ts
import { slashFactory } from "@milkdown/plugin-slash";
export const [mySlashSpec, mySlashPlugin] = slashFactory("my");
```
Just like the tooltip factory:
- `mySlashSpec` is where you put a `PluginSpec` (what ProseMirror needs).
- `mySlashPlugin` turns that spec into a runtime plugin.
---
## 2. A Minimal Vanilla `/` Menu
Below we create a small menu that suggests two commands whenever the user types `/`.
```ts
import { SlashProvider, slashFactory } from "@milkdown/plugin-slash";
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
// DOM content of the menu plain HTML for the demo
const menu = document.createElement("div");
menu.className = "slash-menu";
menu.style.cssText = `
position:absolute;padding:4px 0;background:white;border:1px solid #eee;
box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:6px;font-size:14px;
`;
menu.innerHTML = `<ul style="margin:0;padding:0;list-style:none">
<li data-cmd="h1" style="padding:4px 12px;cursor:pointer">Heading 1</li>
<li data-cmd="bullet" style="padding:4px 12px;cursor:pointer">Bullet List</li>
</ul>`;
// Click handler replace with real commands
menu.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const cmd = target.dataset.cmd;
alert(`Run command: ${cmd}`);
});
// Provider positions & shows above DOM element
const provider = new SlashProvider({
content: menu,
// show the menu when the last character before caret is '/'
shouldShow(view) {
return provider.getContent(view)?.endsWith("/") ?? false;
},
offset: 8,
});
const slash = slashFactory("demo");
const slashConfig = (ctx: Ctx) => {
ctx.set(slash.key, {
view: () => ({
update: provider.update,
destroy: provider.destroy,
}),
});
};
Editor.make().config(slashConfig).use(commonmark).use(slash).create();
```
Key takeaways:
- `SlashProvider` has a helper `getContent(view)` to fetch text before the caret handy for filtering.
- You decide **when to show** the menu via the `shouldShow` callback (default: when last char is `/`).
- The provider only manipulates **position + visibility**; rendering & commands are completely yours.
---
## 3. Framework Examples
### React
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-slash"}
Highlights:
1. A `<SlashMenu/>` React component renders the list.
2. The component root is passed to `SlashProvider` (just like the tooltip demo).
3. React hooks manage internal focus & keyboard navigation.
### Vue
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-slash"}
The Vue version uses `Teleport` to append the menu to `document.body` and `ref` / `watch` for reactivity.
---
## 4. Real-world Feature Crepe Block Menu
Milkdown's **Crepe** UI implements an extensible block-level menu on top of the slash plugin. You'll find the source code at:
```text
packages/crepe/src/feature/block-edit/menu/
```
Notable patterns to look for:
- **Context slices** (`menu` / `menuAPI`) to expose imperative `show` & `hide` methods.
- Filtering commands based on the current text after `/`.
- Preventing the menu inside `code` blocks or lists.
Studying this folder is a great next step once you master the basics.
---
## 5. Summary & Next Steps
- `@milkdown/plugin-slash` gives you caret detection + positioning nothing else.
- UI, behaviour, and commands are fully customisable.
Fork one of the examples above, add your own commands, and you'll have a modern `/` command palette in minutes ✨.
@@ -1,140 +0,0 @@
# Example: Tooltip Plugin
This guide walks you through creating and using **tooltip-based plugins** in Milkdown.
You will learn how the low-level `@milkdown/plugin-tooltip` works and how to build richer experiences on top of it in **vanilla TypeScript**, **React**, and **Vue**.
> **TL;DR** A tooltip in Milkdown is nothing more than a ProseMirror plugin created by `tooltipFactory(id)`.
> It receives position information from the editor and renders any DOM of your choice.
> Everything else (buttons, inputs, styling, framework bindings) can be composed on top of that.
## 1. Anatomy of a Tooltip
---
At its core the tooltip plugin exported from `@milkdown/plugin-tooltip` contains two helpers:
1. **`TooltipProvider`** An utility class powered by [floating-ui](https://floating-ui.com/) to calculate the tooltip position.
2. **`tooltipFactory(id)`** A factory that returns a pair of Milkdown plugin slices which wire the provider into the editor.
The factory is extremely small (≈40 lines):
```ts
import { tooltipFactory } from "@milkdown/plugin-tooltip";
// Create a tooltip identified by the string "my".
export const [myTooltipSpec, myTooltipPlugin] = tooltipFactory("my");
```
The first element (`myTooltipSpec`) is a **ctx slice** that stores a `PluginSpec`, while the second one (`myTooltipPlugin`) is the real ProseMirror plugin which consumes that spec.
## 2. A Minimal Vanilla Tooltip
---
Below is the complete code for a tooltip that shows the **length of the current selection**.
```ts
import { Editor } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { TooltipProvider, tooltipFactory } from "@milkdown/plugin-tooltip";
// 1) Prepare DOM that we will mount into the page.
const el = document.createElement("div");
el.className = "selection-length";
el.style.cssText = `
pointer-events:none;
background:#333;color:#fff;padding:2px 6px;border-radius:4px;font-size:12px;
`;
// 2) Build a provider which updates the content.
const provider = new TooltipProvider({
content: el,
shouldShow: (view) => !!view.state.selection.content().size,
});
// 3) Bridge provider & editor.
const tooltip = tooltipFactory("sel-length");
const tooltipConfig = (ctx: Ctx) => {
ctx.set(selectionTooltipSpec.key, {
view: () => ({
update: provider.update,
destroy: provider.destroy,
}),
});
};
Editor.make().config(tooltipConfig).use(commonmark).use(tooltip).create();
```
Key points:
- We **create** any DOM element we like (`el`).
- `TooltipProvider` tracks the editor position and moves the element.
- `tooltipFactory` wraps the provider into a pluggable slice.
## 3. Framework Examples
---
Sometimes building UI is easier in your favourite framework.
Because the tooltip provider only deals with **DOM elements**, you can freely render React, Vue or Svelte components and pass their root node to the provider.
### React
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-tooltip"}
The React example shows how to:
1. Create a React component (`<SelectionTooltip/>`).
2. Render it into a portal and give the root HTML element to `TooltipProvider`.
3. Re-use React state/hooks while Milkdown takes care of positioning.
### Vue
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-tooltip"}
The Vue example follows the same pattern with `defineComponent` and `teleport`.
## 4. Real-world Examples
---
### 4-1. Link Tooltip (_@milkdown/component/link-tooltip_)
The [link tooltip](https://github.com/Milkdown/milkdown/tree/main/packages/components/src/link-tooltip) demonstrates how to:
- Maintain UI **state** (`preview` vs `edit`) in ctx slices.
- Communicate with the editor through an **API slice** (add / edit / remove links).
- Render framework-agnostic UI inside a tooltip provider.
Have a look at the files below to see those techniques in action:
```text
packages/components/src/link-tooltip/
├── slices.ts # state & API slices
├── tooltips.ts # preview & edit providers
└── component.tsx # (framework examples)
```
### 4-2. Toolbar Feature (_@milkdown/crepe/feature/toolbar_)
The toolbar in the [crepe](https://github.com/Milkdown/milkdown/tree/main/packages/crepe) package pushes the idea further by:
- Using multiple tooltip instances (one per button group).
- Rendering the UI with Vue _inside_ the provider.
- Sharing configuration via ctx slices so that every button is extensible by third-party plugins.
You can browse the implementation starting from
```text
packages/crepe/src/feature/toolbar/component.tsx
```
## 5. Summary & Next Steps
---
- `@milkdown/plugin-tooltip` offers **just enough** abstraction: positioning & lifecycle.
- Everything else **state, styling, framework integration** is totally up to you.
Try to customise one of the examples above, then ship your own tooltip-powered features 🤟.
-174
View File
@@ -1,174 +0,0 @@
# Plugins 101
In this section we will show you the basic information of the plugin.
In most cases, you will not need to write plugins without helpers.
But it can help you understand the plugin system and what happens under the hood.
## Structure Overview
Generally speaking, a plugin will have following structure:
```typescript
import { MilkdownPlugin } from "@milkdown/kit/ctx";
const myPlugin: MilkdownPlugin = (ctx) => {
// #1 prepare plugin
return async () => {
// #2 run plugin
return async () => {
// #3 clean up plugin
};
};
};
```
Each plugin is composed by three parts:
1. _Prepare_: this part will be executed when plugin is registered in milkdown by `.use` method.
2. _Run_: this part will be executed when plugin is actually loaded.
3. _Post_: this part will be executed when plugin is removed by `.remove` method or editor is destroyed.
## Timer
Timer can be used to decide when to load the current plugin and how current plugin can influence other plugin's loading status.
You can use `ctx.wait` to wait a timer to finish.
```typescript
import { MilkdownPlugin, Complete } from "@milkdown/kit/core";
const myPlugin: MilkdownPlugin = (ctx) => {
return async () => {
const start = Date.now();
await ctx.wait(Complete);
const end = Date.now();
console.log("Milkdown load duration: ", end - start);
};
};
```
You can also create your own timer and influence other plugins load time.
For example, let's create a plugin that will fetch markdown content from remote server as editor's default value.
```typescript
import {
MilkdownPlugin,
editorStateTimerCtx,
defaultValueCtx,
createTimer,
} from "@milkdown/kit/core";
const RemoteTimer = createTimer("RemoteTimer");
const remotePlugin: MilkdownPlugin = (ctx) => {
// register timer
ctx.record(RemoteTimer);
return async () => {
// the editorState plugin will wait for this timer to finish before initialize editor state.
ctx.update(editorStateTimerCtx, (timers) => timers.concat(RemoteTimer));
const defaultMarkdown = await fetchMarkdownAPI();
ctx.set(defaultValueCtx, defaultMarkdown);
// mark timer as complete
ctx.done(RemoteTimer);
return async () => {
await SomeAPI();
// remove timer when plugin is removed
ctx.clearTimer(RemoteTimer);
};
};
};
```
It has following steps:
1. We use `createTimer` to create a timer, and use `pre.record` to register it into milkdown.
2. We update `editorStateTimerCtx` to tell the internal `editorState` plugin that before initialize editor state, it should wait our remote fetch process finished.
3. After we get value from `fetchMarkdownAPI`, we set it as `defaultValue` and use `ctx.done` to mark a timer as complete.
## Ctx
We have used `ctx` several times in the above example, now we can try to understand what it is.
Ctx is a data container which is shared in the entire editor instance. It's composed by a lot of slices. Every `slice` has a unique key and a value. You can change the value of a slice by `ctx.set` and `ctx.update`. And you can get the value of a slice by `ctx.get` with the slice key or name. Last but not least, you can remove a slice by `post.remove`.
```typescript
import { MilkdownPlugin, createSlice } from "@milkdown/kit/ctx";
const counterCtx = createSlice(0, "counter");
const counterPlugin: MilkdownPlugin = (ctx) => {
ctx.inject(counterCtx);
return () => {
// count is 0
const count0 = ctx.get(counterCtx);
// set count to 1
ctx.set(counterCtx, 1);
// now count is 1
const count1 = ctx.get(counterCtx);
// set count to n + 2
ctx.update(counterCtx, (prev) => prev + 2);
// now count is 3
const count2 = ctx.get(counterCtx);
// we can also get value by the slice name
const count3 = ctx.get("counter");
return () => {
// remove the slice
ctx.remove(counterCtx);
};
};
};
```
We can use `createSlice` to create a ctx, and use `pre.inject` to inject the ctx into the editor.
And when plugin processing, `ctx.get` can get the value of a ctx, `ctx.set` can set the value of a ctx, and `ctx.update` can update a ctx using callback function.
So, we can use `ctx` combine with `timer` to decide when should a plugin be processed.
```typescript
import {
MilkdownPlugin,
SchemaReady,
Timer,
createSlice,
} from "@milkdown/kit/core";
const examplePluginTimersCtx = createSlice<Timer[]>([], "example-timer");
const examplePlugin: MilkdownPlugin = (ctx) => {
ctx.inject(examplePluginTimersCtx, [SchemaReady]);
return async () => {
await Promise.all(
ctx.get(examplePluginTimersCtx).map((timer) => ctx.wait(timer)),
);
// or we can use a simplified syntax sugar
await ctx.waitTimers(examplePluginTimersCtx);
// do something
};
};
```
With this pattern, if other plugins want to delay the process of `examplePlugin`, all they need to do is just add a timer into `examplePluginTimersCtx` with `ctx.update`.
## Summary
Now let's go back to the plugin structure. Since we have the knowledge of `timer` and `ctx`, we can understand what we should do in each part of a plugin.
1. In `prepare` stage of the plugin, we can use `ctx.record` to register a timer, and use `ctx.inject` to inject a slice.
2. In `run` stage of the plugin, we can use `ctx.wait` to wait a timer to finish, and use `ctx.get` to get the value of a slice. We can also change values of slices by `ctx.set` and `ctx.update`. And we can use `ctx.done` to mark a timer as complete.
3. In `post` stage of the plugin, we can use `ctx.clearTimer` to clear a timer, and use `ctx.remove` to remove a slice.
-28
View File
@@ -1,28 +0,0 @@
# Using Components
Components are features work out of the box that built on top of plugins.
Each component is a separate module. You can use them by importing them from `@milkdown/kit/component/*`.
All components can be used just like plugins.
```ts
import { imageBlock } from "@milkdown/kit/component/image-block";
import { Editor } from "@milkdown/kit/core";
Editor.make().use(/* some other plugins */).use(imageBlock).create();
```
Components are designed to be headless, which means they are not opinionated about the UI.
You can use them to build your own editor UI. Components are built by web components and can be used in any framework.
---
# List of Components
| Name | Description |
| ------------------------------------------------ | ---------------------------------------------------------- |
| [Code Block](/docs/api/component-code-block) | Render code by [Codemirror](https://codemirror.net/) |
| [Image Block](/docs/api/component-image-block) | Render an image as a block |
| [Image Inline](/docs/api/component-image-inline) | Provide placeholder and uploader features for inline image |
| [Link Tooltip](/docs/api/component-link-tooltip) | Provide edit and preview feature for link |
| [List Item](/docs/api/component-list-item-block) | Renderers bullet, ordered and task list by custom renderer |
| [Table Block](/docs/api/component-table-block) | Render table and provides table editing features |
-87
View File
@@ -1,87 +0,0 @@
# Using Plugins
All features in milkdown are provided by plugin.
Such as syntax, components, etc.
Now we can try more plugins:
```typescript
import { Editor } from "@milkdown/kit/core";
import { slash } from "@milkdown/kit/plugin/slash";
import { tooltip } from "@milkdown/kit/plugin/tooltip";
import { commonmark } from "@milkdown/kit/preset/commonmark";
Editor.make().use(commonmark).use(tooltip).use(slash).create();
```
---
## Toggling Plugins
You can also toggle plugins programmatically:
```typescript
import { Editor } from "@milkdown/kit/core";
import { someMilkdownPlugin } from "some-milkdown-plugin";
const editor = await Editor.config(configForPlugin)
.use(someMilkdownPlugin)
.create();
// remove plugin
await editor.remove(someMilkdownPlugin);
// remove config
editor.removeConfig(configForPlugin);
// add another plugin
editor.use(anotherMilkdownPlugin);
// Recreate the editor to apply changes.
await editor.create();
```
---
## Official Plugins
Milkdown provides the following official plugins:
### Plugins provided by `@milkdown/kit`:
> 🙋‍♀️Why not all plugins are available in `@milkdown/kit`?
>
> `@milkdown/kit` is a collection of plugins that are commonly used in the editor.
> If you want to use a plugin that is not in `@milkdown/kit`, you can install it separately.
> The plugins in `@milkdown/kit` are also stable and well-tested.
| Package Name | Description |
| -------------------------------------------------------------- | --------------------------------------------------------- |
| [@milkdown/kit/preset/commonmark](/docs/api/preset-commonmark) | Add [commonmark](https://commonmark.org/) syntax support. |
| [@milkdown/kit/preset/gfm](/docs/api/preset-gfm) | Add [gfm](https://github.github.com/gfm/) syntax support. |
| [@milkdown/kit/plugin/history](/docs/api/plugin-history) | Add undo & redo support. |
| [@milkdown/kit/plugin/clipboard](/docs/api/plugin-clipboard) | Add markdown copy & paste support. |
| [@milkdown/kit/plugin/cursor](/docs/api/plugin-cursor) | Add drop & gap cursor. |
| [@milkdown/kit/plugin/listener](/docs/api/plugin-listener) | Add listener support. |
| [@milkdown/kit/plugin/indent](/docs/api/plugin-indent) | Add tab indent support. |
| [@milkdown/kit/plugin/upload](/docs/api/plugin-upload) | Add drop and upload support. |
| [@milkdown/kit/plugin/block](/docs/api/plugin-block) | Add a drag handle for every block node. |
| [@milkdown/kit/plugin/tooltip](/docs/api/plugin-tooltip) | Add universal tooltip support. |
| [@milkdown/kit/plugin/slash](/docs/api/plugin-slash) | Add universal slash commands support. |
### Other Plugins:
- [@milkdown/plugin-collab](/docs/api/plugin-collab)
Add collaborative editing support, powered by [yjs](https://docs.yjs.dev/).
- [@milkdown/plugin-prism](/docs/api/plugin-prism)
Add [prism](https://prismjs.com/) support for code block highlight.
- [@milkdown/plugin-emoji](/docs/api/plugin-emoji)
Add emoji shortcut support (something like `:+1:`), and use [twemoji](https://twemoji.twitter.com/) to display emoji.
## Community plugins
Check out [awesome-milkdown](https://github.com/Milkdown/awesome-milkdown) to find community plugins. You can also submit a PR to list your plugins there.
-48
View File
@@ -1,48 +0,0 @@
# Angular
We don't provide Angular support out of box, but you can use the vanilla version with it easily.
## Install the Dependencies
```bash
# install with npm
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Create a Component
Create a component is pretty easy.
```html
<!-- editor.component.html -->
<div #editorRef></div>
```
```typescript
// editor.component.ts
import { Component, ElementRef, ViewChild } from "@angular/core";
import { defaultValueCtx, Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
@Component({
templateUrl: "./editor.component.html",
})
export class AppComponent {
@ViewChild("editorRef") editorRef: ElementRef;
defaultValue = "# Milkdown x Angular";
ngAfterViewInit() {
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, this.editorRef.nativeElement);
ctx.set(defaultValueCtx, this.defaultValue);
})
.config(nord)
.use(commonmark)
.create();
}
}
```
-51
View File
@@ -1,51 +0,0 @@
# Next.js
Since we provide [react](/docs/recipes/react) support out of box, we can use it directly in [Next.js](https://nextjs.org/).
## Install the Dependencies
Except the `@milkdown/kit` and theme. We need to install the `@milkdown/react`, which provide lots of abilities for react in milkdown.
```bash
# install with npm
npm install @milkdown/react
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Create a Component
Create a component is pretty easy.
```tsx
import { Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/react";
import { nord } from "@milkdown/theme-nord";
import React from "react";
const MilkdownEditor: React.FC = () => {
const { editor } = useEditor((root) =>
Editor.make()
.config(nord)
.config((ctx) => {
ctx.set(rootCtx, root);
})
.use(commonmark),
);
return <Milkdown />;
};
export const MilkdownEditorWrapper: React.FC = () => {
return (
<MilkdownProvider>
<MilkdownEditor />
</MilkdownProvider>
);
};
```
## Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/next-commonmark"}
-82
View File
@@ -1,82 +0,0 @@
# NuxtJS
Since we provide [vue](/docs/recipes/vue) support out of box, we can use it directly in [NuxtJS](https://v3.nuxtjs.org/).
> NuxtJS version should be 3.x.
## Install the Dependencies
Except the `@milkdown/kit` and theme. We need to install the `@milkdown/vue`, which provide lots of abilities for vue in milkdown.
```bash
# install with npm
npm install @milkdown/vue
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Create a Component
Create a component is pretty easy.
First, we need to create a `MilkdownEditor` component.
```html
<!-- MilkdownEditor.vue -->
<template>
<Milkdown />
</template>
<script>
import { Editor, rootCtx, defaultValueCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
import { Milkdown, useEditor } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "Milkdown",
components: {
Milkdown,
},
setup: () => {
useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
})
.config(nord)
.use(commonmark),
);
},
});
</script>
```
Then, we need to create a `MilkdownEditorWrapper` component.
```html
<!-- MilkdownEditorWrapper.vue -->
<template>
<MilkdownProvider>
<MilkdownEditor />
</MilkdownProvider>
</template>
<script>
import { MilkdownProvider } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "MilkdownEditorWrapper",
components: {
MilkdownProvider,
},
setup: () => {},
});
</script>
```
## Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/nuxt-commonmark"}
-213
View File
@@ -1,213 +0,0 @@
# React Integration
Milkdown provides first-class React support with dedicated packages and hooks for seamless integration. You can choose between Crepe, our feature-rich WYSIWYG editor, or the core Milkdown editor for more customization options.
## Using Crepe
---
Crepe is a powerful, feature-rich Markdown editor built on top of Milkdown that provides a more user-friendly editing experience.
### Installation
```bash
npm install @milkdown/crepe @milkdown/react @milkdown/kit
```
### Implementation
```tsx
import { Crepe } from "@milkdown/crepe";
import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/react";
const CrepeEditor: React.FC = () => {
const { get } = useEditor((root) => {
return new Crepe({ root });
});
return <Milkdown />;
};
export const MilkdownEditorWrapper: React.FC = () => {
return (
<MilkdownProvider>
<CrepeEditor />
</MilkdownProvider>
);
};
```
### Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-crepe"}
## Using Milkdown
---
For more advanced use cases or when you need full control over the editor's configuration, you can use the core Milkdown editor directly.
### Install Dependencies
```bash
npm install @milkdown/react @milkdown/kit
```
### Basic Usage
Here's a minimal example to get started:
```tsx
import { Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/react";
import { nord } from "@milkdown/theme-nord";
const MilkdownEditor: React.FC = () => {
const { get } = useEditor((root) =>
Editor.make()
.config(nord)
.config((ctx) => {
ctx.set(rootCtx, root);
})
.use(commonmark),
);
return <Milkdown />;
};
export const MilkdownEditorWrapper: React.FC = () => {
return (
<MilkdownProvider>
<MilkdownEditor />
</MilkdownProvider>
);
};
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-commonmark"}
## Advanced Usage
---
### Accessing Editor Instance
The `useInstance()` hook can only be used within components that are children of `MilkdownProvider`. It returns a tuple containing a loading state and a getter function to access the editor instance.
```tsx
import { useInstance } from "@milkdown/react";
import { getMarkdown } from "@milkdown/utils";
// ❌ This won't work - ParentComponent is outside MilkdownProvider
const ParentComponent: React.FC = () => {
const [isLoading, getInstance] = useInstance(); // This will be [true, () => undefined]
return <MilkdownEditorWrapper />;
};
// ✅ This is the correct way - EditorControls is inside MilkdownProvider
const EditorControls: React.FC = () => {
const [isLoading, getInstance] = useInstance();
const handleSave = () => {
if (isLoading) return;
const editor = getInstance();
if (!editor) return;
const content = editor.action(getMarkdown());
// Do something with the content
};
return (
<button onClick={handleSave} disabled={isLoading}>
Save
</button>
);
};
// ✅ Proper component structure
const EditorWithControls: React.FC = () => {
return (
<MilkdownProvider>
<MilkdownEditorWrapper />
<EditorControls />
</MilkdownProvider>
);
};
```
### Best Practices
1. **Component Structure**
- Keep the editor component separate from business logic
- Wrap the editor with `MilkdownProvider` at the highest necessary level
- Use TypeScript for better type safety
2. **Performance**
- Memoize the editor configuration if it's complex
- Use React.memo for the editor component if needed
- Avoid unnecessary re-renders of the editor
### Common Use Cases
**Form Integration**
```tsx
const FormWithEditor: React.FC = () => {
const [isLoading, getInstance] = useInstance();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isLoading) return;
const editor = getInstance();
if (!editor) return;
const content = editor.action(getMarkdown());
// Submit form with content
};
return (
<form onSubmit={handleSubmit}>
<MilkdownEditorWrapper />
<button type="submit" disabled={isLoading}>
Submit
</button>
</form>
);
};
```
**Auto-save**
```tsx
import { Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
import { Milkdown, useEditor } from "@milkdown/react";
const AutoSaveEditor: React.FC = () => {
const { get } = useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
// Add markdown listener for auto-save
ctx.get(listenerCtx).markdownUpdated((ctx, markdown) => {
// Save content to your backend or storage
saveToBackend(markdown);
});
})
.use(commonmark)
.use(listener),
);
return <Milkdown />;
};
```
## More Examples
---
- [Examples Repository](https://github.com/Milkdown/examples)
-46
View File
@@ -1,46 +0,0 @@
# SolidJS
We don't provide SolidJS support out of box, but you can use the vanilla version with it easily.
## Install the Dependencies
```bash
# install with npm
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Create a Component
Create a component is pretty easy.
```tsx
import { defaultValueCtx, Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
import { onCleanup, onMount } from "solid-js";
const Milkdown = () => {
let ref;
let editor;
onMount(async () => {
editor = await Editor.make()
.config((ctx) => {
ctx.set(rootCtx, ref);
})
.config(nord)
.use(commonmark)
.create();
});
onCleanup(() => {
editor.destroy();
});
return <div ref={ref} />;
};
```
## Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/solid-commonmark"}
-45
View File
@@ -1,45 +0,0 @@
# Svelte
We don't provide Svelte support out of box, but you can use the vanilla version with it easily.
## Install the Dependencies
```bash
# install with npm
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Creating a Component
Creating a component is pretty easy.
```html
<script>
import { Editor, rootCtx, defaultValueCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
function editor(dom) {
// to obtain the editor instance we need to store a reference of the editor.
const MakeEditor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, dom);
})
.config(nord)
.use(commonmark)
.create();
MakeEditor.then((editor) => {
// here you have access to the editor instance.
// const exampleContent = "# Hello World!";
// editor.action(replaceAll(exampleContent));
});
}
</script>
<div use:editor />
```
## Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/svelte-commonmark"}
-294
View File
@@ -1,294 +0,0 @@
# Vue Integration
Milkdown provides first-class Vue support with dedicated packages and hooks for seamless integration. You can choose between Crepe, our feature-rich WYSIWYG editor, or the core Milkdown editor for more customization options.
> Vue version should be 3.x
## Using Crepe
---
Crepe is a powerful, feature-rich Markdown editor built on top of Milkdown that provides a more user-friendly editing experience.
### Installation
```bash
npm install @milkdown/crepe @milkdown/vue @milkdown/kit
```
### Implementation
```vue
<!-- MilkdownEditor.vue -->
<template>
<Milkdown />
</template>
<script>
import { Crepe } from "@milkdown/crepe";
import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "MilkdownEditor",
components: {
Milkdown,
},
setup: () => {
const { get } = useEditor((root) => {
return new Crepe({ root });
});
},
});
</script>
<!-- MilkdownEditorWrapper.vue -->
<template>
<MilkdownProvider>
<MilkdownEditor />
</MilkdownProvider>
</template>
<script>
import { MilkdownProvider } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "MilkdownEditorWrapper",
components: {
MilkdownProvider,
},
});
</script>
```
### Online Demo
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-crepe"}
## Using Milkdown
---
For more advanced use cases or when you need full control over the editor's configuration, you can use the core Milkdown editor directly.
### Install Dependencies
```bash
npm install @milkdown/vue @milkdown/kit @milkdown/theme-nord
```
### Basic Usage
Here's a minimal example to get started:
```vue
<!-- MilkdownEditor.vue -->
<template>
<Milkdown />
</template>
<script>
import { Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
import { Milkdown, useEditor } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "MilkdownEditor",
components: {
Milkdown,
},
setup: () => {
const { get } = useEditor((root) =>
Editor.make()
.config(nord)
.config((ctx) => {
ctx.set(rootCtx, root);
})
.use(commonmark),
);
},
});
</script>
<!-- MilkdownEditorWrapper.vue -->
<template>
<MilkdownProvider>
<MilkdownEditor />
</MilkdownProvider>
</template>
<script>
import { MilkdownProvider } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "MilkdownEditorWrapper",
components: {
MilkdownProvider,
},
});
</script>
```
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-commonmark"}
## Advanced Usage
---
### Accessing Editor Instance
The `useInstance()` hook can only be used within components that are children of `MilkdownProvider`. It returns a tuple containing a loading state and a getter function to access the editor instance.
```vue
<!-- EditorControls.vue -->
<template>
<button @click="handleSave" :disabled="isLoading">Save</button>
</template>
<script>
import { useInstance } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "EditorControls",
setup: () => {
const [isLoading, getInstance] = useInstance();
const handleSave = () => {
if (isLoading.value) return;
const editor = getInstance();
if (!editor) return;
const content = editor.getMarkdown();
// Do something with the content
};
return {
isLoading,
handleSave,
};
},
});
</script>
<!-- EditorWithControls.vue -->
<template>
<MilkdownProvider>
<MilkdownEditor />
<EditorControls />
</MilkdownProvider>
</template>
<script>
import { MilkdownProvider } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "EditorWithControls",
components: {
MilkdownProvider,
},
});
</script>
```
### Best Practices
1. **Component Structure**
- Keep the editor component separate from business logic
- Wrap the editor with `MilkdownProvider` at the highest necessary level
- Use TypeScript for better type safety
2. **Performance**
- Memoize the editor configuration if it's complex
- Use Vue's `shallowRef` for editor instance if needed
- Avoid unnecessary re-renders of the editor
### Common Use Cases
**Form Integration**
```vue
<template>
<form @submit.prevent="handleSubmit">
<MilkdownEditorWrapper />
<button type="submit" :disabled="isLoading">Submit</button>
</form>
</template>
<script>
import { useInstance } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "FormWithEditor",
setup: () => {
const [isLoading, getInstance] = useInstance();
const handleSubmit = () => {
if (isLoading.value) return;
const editor = getInstance();
if (!editor) return;
const content = editor.getMarkdown();
// Submit form with content
};
return {
isLoading,
handleSubmit,
};
},
});
</script>
```
**Auto-save**
```vue
<template>
<Milkdown />
</template>
<script>
import { Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
import { Milkdown, useEditor } from "@milkdown/vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "AutoSaveEditor",
components: {
Milkdown,
},
setup: () => {
const { get } = useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
// Add markdown listener for auto-save
ctx.get(listenerCtx).markdownUpdated((ctx, markdown) => {
// Save content to your backend or storage
saveToBackend(markdown);
});
})
.use(commonmark)
.use(listener),
);
},
});
</script>
```
## More Examples
---
- [Examples Repository](https://github.com/Milkdown/examples)
-44
View File
@@ -1,44 +0,0 @@
# Vue2
We don't provide Vue2 support out of box, but you can use the vanilla version with it easily.
## Install the Dependencies
```bash
# install with npm
npm install @milkdown/kit
npm install @milkdown/theme-nord
```
## Create a Component
Create a component is pretty easy.
```html
<template>
<div ref="editor"></div>
</template>
<script>
import { defaultValueCtx, Editor, rootCtx } from "@milkdown/kit/core";
import { commonmark } from "@milkdown/kit/preset/commonmark";
import { nord } from "@milkdown/theme-nord";
export default {
name: "Editor",
props: {
msg: String,
},
mounted() {
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, this.$refs.editor);
ctx.set(defaultValueCtx, this.$props.msg);
})
.config(nord)
.use(commonmark)
.create();
},
};
</script>
```
+51 -4
View File
@@ -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,12 +24,14 @@
"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",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-captcha": "^0.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -4501,7 +4503,6 @@
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -4570,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",
@@ -7301,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",
@@ -8939,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",
@@ -9564,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",
@@ -11552,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",
@@ -12107,6 +12145,15 @@
"vue": "^3.5.0"
}
},
"node_modules/vue3-captcha": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/vue3-captcha/-/vue3-captcha-0.3.4.tgz",
"integrity": "sha512-mQrti94ZADcXCDVrFTZm5uyxQix+sYHMzylQOQyGUD+RENBcFR9MoSTQvQ7jNOKd2eM6miggKIY2DsB264FGdA==",
"license": "MIT",
"dependencies": {
"vue": "^3.2.25"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "llm-in-text",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,12 +27,14 @@
"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",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-captcha": "^0.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'llm-in-text-v1';
const CACHE_NAME = 'llm-in-text-v2';
const APP_SHELL_ASSETS = [
'/',
'/index.html',
+1 -2
View File
@@ -3,7 +3,7 @@ testpaths = backend/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --cov=backend.main --cov=backend.llm --cov=backend.prompt --cov=backend.geoip --cov=backend.prompts --cov=backend.tts_asr --cov-report=term-missing --cov-report=html --cov-fail-under=90
addopts = -v --tb=short --cov=backend --cov-report=term-missing --cov-report=html
[coverage:run]
omit =
@@ -11,7 +11,6 @@ omit =
backend/test_*.py
[coverage:report]
fail_under = 90
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
@@ -0,0 +1,42 @@
rank,engine,score,avg_elapsed_ms,avg_results,queries_with_results,query_count,error_count,unresponsive_count,result_engines
1,bing,162.5,454.4,8.5,2,2,0,0,"[""bing""]"
2,yep,149.4,6559.8,20,2,2,0,0,"[""yep""]"
3,360search,140.0,612.9,4,2,2,0,0,"[""360search""]"
4,searchmysite,130.71,6429.3,10,2,2,0,0,"[""searchmysite""]"
5,startpage,129.21,6128.9,10,2,2,0,0,"[""startpage""]"
6,crowdview,122.0,1811.5,20,1,2,0,0,"[""crowdview""]"
7,duckduckgo news,120.37,2963.2,15,1,2,0,0,"[""duckduckgo news""]"
8,mwmbl,109.85,3615.0,17,1,2,0,0,"[""mwmbl""]"
9,openalex,92.51,7499.3,10,2,2,0,0,"[""openalex""]"
10,brave,92.0,1757.4,8.5,1,2,0,1,"[""brave""]"
11,stackoverflow,91.21,6779.4,5.5,2,2,0,0,"[""stackoverflow""]"
12,crossref,86.86,9674.9,19,2,2,0,0,"[""crossref""]"
13,reuters,83.71,5279.0,10,1,2,0,0,"[""reuters""]"
14,naver news,82.92,5257.9,5,1,2,0,0,"[""naver news""]"
15,github,77.74,5876.0,15,1,2,0,0,"[""github""]"
16,gitlab,67.0,6850.2,10,1,2,0,0,"[""gitlab""]"
17,microsoft learn,63.64,10236.2,9,2,2,0,0,"[""microsoft learn""]"
18,docker hub,63.07,5843.1,5,1,2,0,0,"[""docker hub""]"
19,hackernews,61.14,7536.4,15,1,2,0,0,"[""hackernews""]"
20,bing news,60.0,647.8,0,0,2,0,0,[]
21,brave.news,60.0,1949.2,0,0,2,0,0,[]
22,askubuntu,59.8,6669.6,5,1,2,0,0,"[""askubuntu""]"
23,npm,57.04,7946.2,12.5,1,2,0,0,"[""npm""]"
24,mdn,56.1,10389.9,6,2,2,0,0,"[""mdn""]"
25,arxiv,54.31,7218.6,5,1,2,0,0,"[""arxiv""]"
26,superuser,44.68,7981.8,4,1,2,0,0,"[""superuser""]"
27,pkg.go.dev,40.68,8782.4,25,1,2,0,0,"[""pkg.go.dev""]"
28,sourcehut,22.05,9645.2,1,1,2,0,0,"[""sourcehut""]"
29,startpage news,16.41,6859.0,0,0,2,0,0,[]
30,wikipedia,16.29,6871.1,0,0,2,0,0,[]
31,pubmed,15.5,9049.8,10,1,2,0,1,"[""pubmed""]"
32,pypi,1.03,8397.4,0,0,2,0,0,[]
33,semantic scholar,0,6657.8,0,0,2,0,2,[]
34,wikidata,0,8565.3,0,0,2,0,2,[]
35,lib.rs,0,9375.0,0,0,2,0,2,[]
36,qwant,0,10274.0,0,0,2,0,2,[]
37,qwant news,0,10529.5,0,0,2,0,2,[]
38,mojeek,0,11256.4,0,0,2,0,2,[]
39,mojeek news,0,11503.0,0,0,2,0,2,[]
40,seznam,0,12367.0,0,0,2,0,2,[]
41,wiby,0,15006.0,0,0,2,2,0,[]
1 rank engine score avg_elapsed_ms avg_results queries_with_results query_count error_count unresponsive_count result_engines
2 1 bing 162.5 454.4 8.5 2 2 0 0 ["bing"]
3 2 yep 149.4 6559.8 20 2 2 0 0 ["yep"]
4 3 360search 140.0 612.9 4 2 2 0 0 ["360search"]
5 4 searchmysite 130.71 6429.3 10 2 2 0 0 ["searchmysite"]
6 5 startpage 129.21 6128.9 10 2 2 0 0 ["startpage"]
7 6 crowdview 122.0 1811.5 20 1 2 0 0 ["crowdview"]
8 7 duckduckgo news 120.37 2963.2 15 1 2 0 0 ["duckduckgo news"]
9 8 mwmbl 109.85 3615.0 17 1 2 0 0 ["mwmbl"]
10 9 openalex 92.51 7499.3 10 2 2 0 0 ["openalex"]
11 10 brave 92.0 1757.4 8.5 1 2 0 1 ["brave"]
12 11 stackoverflow 91.21 6779.4 5.5 2 2 0 0 ["stackoverflow"]
13 12 crossref 86.86 9674.9 19 2 2 0 0 ["crossref"]
14 13 reuters 83.71 5279.0 10 1 2 0 0 ["reuters"]
15 14 naver news 82.92 5257.9 5 1 2 0 0 ["naver news"]
16 15 github 77.74 5876.0 15 1 2 0 0 ["github"]
17 16 gitlab 67.0 6850.2 10 1 2 0 0 ["gitlab"]
18 17 microsoft learn 63.64 10236.2 9 2 2 0 0 ["microsoft learn"]
19 18 docker hub 63.07 5843.1 5 1 2 0 0 ["docker hub"]
20 19 hackernews 61.14 7536.4 15 1 2 0 0 ["hackernews"]
21 20 bing news 60.0 647.8 0 0 2 0 0 []
22 21 brave.news 60.0 1949.2 0 0 2 0 0 []
23 22 askubuntu 59.8 6669.6 5 1 2 0 0 ["askubuntu"]
24 23 npm 57.04 7946.2 12.5 1 2 0 0 ["npm"]
25 24 mdn 56.1 10389.9 6 2 2 0 0 ["mdn"]
26 25 arxiv 54.31 7218.6 5 1 2 0 0 ["arxiv"]
27 26 superuser 44.68 7981.8 4 1 2 0 0 ["superuser"]
28 27 pkg.go.dev 40.68 8782.4 25 1 2 0 0 ["pkg.go.dev"]
29 28 sourcehut 22.05 9645.2 1 1 2 0 0 ["sourcehut"]
30 29 startpage news 16.41 6859.0 0 0 2 0 0 []
31 30 wikipedia 16.29 6871.1 0 0 2 0 0 []
32 31 pubmed 15.5 9049.8 10 1 2 0 1 ["pubmed"]
33 32 pypi 1.03 8397.4 0 0 2 0 0 []
34 33 semantic scholar 0 6657.8 0 0 2 0 2 []
35 34 wikidata 0 8565.3 0 0 2 0 2 []
36 35 lib.rs 0 9375.0 0 0 2 0 2 []
37 36 qwant 0 10274.0 0 0 2 0 2 []
38 37 qwant news 0 10529.5 0 0 2 0 2 []
39 38 mojeek 0 11256.4 0 0 2 0 2 []
40 39 mojeek news 0 11503.0 0 0 2 0 2 []
41 40 seznam 0 12367.0 0 0 2 0 2 []
42 41 wiby 0 15006.0 0 0 2 2 0 []
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
# SearXNG candidate quality report
- Generated: 2026-06-09T05:37:25.909553+00:00
- Concurrency: 16
- Total elapsed: 44077.7 ms
- Queries: OpenAI, 人工智能 最新进展
| Rank | Engine | Score | Avg ms | Avg results | Result queries | Errors | Unresponsive | Result engines |
|---:|---|---:|---:|---:|---:|---:|---:|---|
| 1 | bing | 162.5 | 454.4 | 8.5 | 2/2 | 0 | 0 | bing |
| 2 | yep | 149.4 | 6559.8 | 20 | 2/2 | 0 | 0 | yep |
| 3 | 360search | 140.0 | 612.9 | 4 | 2/2 | 0 | 0 | 360search |
| 4 | searchmysite | 130.71 | 6429.3 | 10 | 2/2 | 0 | 0 | searchmysite |
| 5 | startpage | 129.21 | 6128.9 | 10 | 2/2 | 0 | 0 | startpage |
| 6 | crowdview | 122.0 | 1811.5 | 20 | 1/2 | 0 | 0 | crowdview |
| 7 | duckduckgo news | 120.37 | 2963.2 | 15 | 1/2 | 0 | 0 | duckduckgo news |
| 8 | mwmbl | 109.85 | 3615.0 | 17 | 1/2 | 0 | 0 | mwmbl |
| 9 | openalex | 92.51 | 7499.3 | 10 | 2/2 | 0 | 0 | openalex |
| 10 | brave | 92.0 | 1757.4 | 8.5 | 1/2 | 0 | 1 | brave |
| 11 | stackoverflow | 91.21 | 6779.4 | 5.5 | 2/2 | 0 | 0 | stackoverflow |
| 12 | crossref | 86.86 | 9674.9 | 19 | 2/2 | 0 | 0 | crossref |
| 13 | reuters | 83.71 | 5279.0 | 10 | 1/2 | 0 | 0 | reuters |
| 14 | naver news | 82.92 | 5257.9 | 5 | 1/2 | 0 | 0 | naver news |
| 15 | github | 77.74 | 5876.0 | 15 | 1/2 | 0 | 0 | github |
| 16 | gitlab | 67.0 | 6850.2 | 10 | 1/2 | 0 | 0 | gitlab |
| 17 | microsoft learn | 63.64 | 10236.2 | 9 | 2/2 | 0 | 0 | microsoft learn |
| 18 | docker hub | 63.07 | 5843.1 | 5 | 1/2 | 0 | 0 | docker hub |
| 19 | hackernews | 61.14 | 7536.4 | 15 | 1/2 | 0 | 0 | hackernews |
| 20 | bing news | 60.0 | 647.8 | 0 | 0/2 | 0 | 0 | |
| 21 | brave.news | 60.0 | 1949.2 | 0 | 0/2 | 0 | 0 | |
| 22 | askubuntu | 59.8 | 6669.6 | 5 | 1/2 | 0 | 0 | askubuntu |
| 23 | npm | 57.04 | 7946.2 | 12.5 | 1/2 | 0 | 0 | npm |
| 24 | mdn | 56.1 | 10389.9 | 6 | 2/2 | 0 | 0 | mdn |
| 25 | arxiv | 54.31 | 7218.6 | 5 | 1/2 | 0 | 0 | arxiv |
| 26 | superuser | 44.68 | 7981.8 | 4 | 1/2 | 0 | 0 | superuser |
| 27 | pkg.go.dev | 40.68 | 8782.4 | 25 | 1/2 | 0 | 0 | pkg.go.dev |
| 28 | sourcehut | 22.05 | 9645.2 | 1 | 1/2 | 0 | 0 | sourcehut |
| 29 | startpage news | 16.41 | 6859.0 | 0 | 0/2 | 0 | 0 | |
| 30 | wikipedia | 16.29 | 6871.1 | 0 | 0/2 | 0 | 0 | |
| 31 | pubmed | 15.5 | 9049.8 | 10 | 1/2 | 0 | 1 | pubmed |
| 32 | pypi | 1.03 | 8397.4 | 0 | 0/2 | 0 | 0 | |
| 33 | semantic scholar | 0 | 6657.8 | 0 | 0/2 | 0 | 2 | |
| 34 | wikidata | 0 | 8565.3 | 0 | 0/2 | 0 | 2 | |
| 35 | lib.rs | 0 | 9375.0 | 0 | 0/2 | 0 | 2 | |
| 36 | qwant | 0 | 10274.0 | 0 | 0/2 | 0 | 2 | |
| 37 | qwant news | 0 | 10529.5 | 0 | 0/2 | 0 | 2 | |
| 38 | mojeek | 0 | 11256.4 | 0 | 0/2 | 0 | 2 | |
| 39 | mojeek news | 0 | 11503.0 | 0 | 0/2 | 0 | 2 | |
| 40 | seznam | 0 | 12367.0 | 0 | 0/2 | 0 | 2 | |
| 41 | wiby | 0 | 15006.0 | 0 | 0/2 | 2 | 0 | |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
# SearXNG engine timing report
- Generated: 2026-06-09T05:21:11.045325+00:00
- Query: `OpenAI`
- Enabled engines tested: 244
- request_timeout: 6.0s
- max_request_timeout: 8.0s
- videos tab enabled: False
| Engine | Elapsed ms | Results | Unresponsive | Errors |
|---|---:|---:|---|---|
| duckduckgo news | 1025.9 | 0 | | |
| bt4g | 1026.1 | 0 | | |
| openstreetmap | 1026.1 | 0 | | |
| wikisource | 1026.3 | 0 | | |
| pub.dev | 1027.5 | 0 | | |
| swisscows images | 1027.5 | 0 | | |
| nixos wiki | 1027.6 | 0 | | |
| flickr_api | 1027.9 | 0 | | |
| baidu images | 1028.0 | 0 | | |
| voidlinux | 1028.1 | 0 | | |
| library genesis | 1028.7 | 0 | | |
| mwmbl | 1028.7 | 0 | | |
| chinaso news | 1029.3 | 0 | | |
| semantic scholar | 1029.4 | 0 | | |
| uxwing | 1029.4 | 0 | | |
| ansa | 1029.5 | 0 | | |
| startpage | 1029.6 | 0 | | |
| heexy | 1029.7 | 0 | | |
| naver | 1029.7 | 0 | | |
| askubuntu | 1029.8 | 0 | | |
| openalex | 1029.8 | 0 | | |
| mymemory translated | 1029.9 | 0 | | |
| public domain image archive | 1029.9 | 0 | | |
| fynd | 1030.0 | 0 | | |
| gentoo | 1030.0 | 0 | | |
| reuters | 1030.1 | 0 | | |
| heexy images | 1030.2 | 0 | | |
| hackernews | 1030.3 | 0 | | |
| huggingface | 1030.3 | 0 | | |
| mojeek images | 1030.3 | 0 | | |
| openairedatasets | 1030.3 | 0 | | |
| codeberg | 1030.4 | 0 | | |
| goodreads | 1030.4 | 0 | | |
| deezer | 1030.5 | 0 | | |
| gitea.com | 1030.5 | 0 | | |
| lingva | 1030.5 | 0 | | |
| flaticon | 1030.6 | 0 | | |
| huggingface datasets | 1030.7 | 0 | | |
| ebay | 1030.8 | 0 | | |
| radio browser | 1030.8 | 0 | | |
| artic | 1030.9 | 0 | | |
| soundcloud | 1030.9 | 0 | | |
| wikivoyage | 1031.0 | 0 | | |
| 1337x | 1031.1 | 0 | | |
| bandcamp | 1031.1 | 0 | | |
| qwant images | 1031.1 | 0 | | |
| tagesschau | 1031.1 | 0 | | |
| z-library | 1031.2 | 0 | | |
| fyyd | 1031.3 | 0 | | |
| apple maps | 1031.4 | 0 | | |
| tootfinder | 1031.4 | 0 | | |
| wikinews | 1031.4 | 0 | | |
| superuser | 1031.5 | 0 | | |
| etymonline | 1031.6 | 0 | | |
| crowdview | 1031.8 | 0 | | |
| lobste.rs | 1031.9 | 0 | | |
| wikicommons.audio | 1031.9 | 0 | | |
| mozhi | 1032.0 | 0 | | |
| artstation | 1032.1 | 0 | | |
| duckduckgo | 1032.1 | 0 | | |
| quark | 1032.1 | 0 | | |
| apk mirror | 1032.2 | 0 | | |
| genius | 1032.2 | 0 | | |
| moviepilot | 1032.2 | 0 | | |
| dictzone | 1032.3 | 0 | | |
| library of congress | 1032.3 | 0 | | |
| naver images | 1032.3 | 0 | | |
| packagist | 1032.3 | 0 | | |
| gabanza | 1032.4 | 0 | | |
| lemmy comments | 1032.4 | 0 | | |
| microsoft learn | 1032.4 | 0 | | |
| fdroid | 1032.5 | 0 | | |
| mojeek news | 1032.5 | 0 | | |
| ipernity | 1032.6 | 0 | | |
| sogou wechat | 1032.6 | 0 | | |
| chefkoch | 1032.7 | 0 | | |
| duckduckgo images | 1032.7 | 0 | | |
| yep | 1032.7 | 0 | | |
| apple app store | 1032.8 | 0 | | |
| bitbucket | 1032.8 | 0 | | |
| reddit | 1032.8 | 0 | | |
| aol images | 1032.9 | 0 | | |
| arxiv | 1032.9 | 0 | | |
| chinaso images | 1032.9 | 0 | | |
| metacpan | 1032.9 | 0 | | |
| swisscows | 1032.9 | 0 | | |
| bing news | 1033.0 | 0 | | |
| libretranslate | 1033.0 | 0 | | |
| wikispecies | 1033.0 | 0 | | |
| baidu | 1033.1 | 0 | | |
| brave | 1033.1 | 0 | | |
| quark images | 1033.1 | 0 | | |
| sogou images | 1033.1 | 0 | | |
| azure | 1033.2 | 0 | | |
| braveapi | 1033.2 | 0 | | |
| discuss.python | 1033.2 | 0 | | |
| springer nature | 1033.2 | 0 | | |
| findthatmeme | 1033.3 | 0 | | |
| lemmy communities | 1033.3 | 0 | | |
| solidtorrents | 1033.3 | 0 | | |
| startpage news | 1033.3 | 0 | | |
| 1x | 1033.4 | 0 | | |
| mdn | 1033.4 | 0 | | |
| openrepos | 1033.4 | 0 | | |
| sourcehut | 1033.4 | 0 | | |
| yandex music | 1033.4 | 0 | | |
| cloudflareai | 1033.5 | 0 | | |
| gitlab | 1033.5 | 0 | | |
| openairepublications | 1033.5 | 0 | | |
| yandex images | 1033.5 | 0 | | |
| currency | 1033.6 | 0 | | |
| tineye | 1033.6 | 0 | | |
| docker hub | 1033.7 | 0 | | |
| grokipedia | 1033.7 | 0 | | |
| wolframalpha | 1033.7 | 0 | | |
| openverse | 1033.8 | 0 | | |
| woxikon.de synonyme | 1033.8 | 0 | | |
| qwant | 1033.9 | 0 | | |
| wordnik | 1033.9 | 0 | | |
| piratebay | 1034.0 | 0 | | |
| lemmy users | 1034.1 | 0 | | |
| seekninja | 1034.1 | 0 | | |
| startpage images | 1034.1 | 0 | | |
| openlibrary | 1034.2 | 0 | | |
| wikicommons.files | 1034.2 | 0 | | |
| rottentomatoes | 1034.3 | 0 | | |
| emojipedia | 1034.4 | 0 | | |
| marginalia | 1034.4 | 0 | | |
| naver news | 1034.4 | 0 | | |
| baidu kaifa | 1034.5 | 0 | | |
| elasticsearch | 1034.5 | 0 | | |
| gmx | 1034.5 | 0 | | |
| mastodon hashtags | 1034.5 | 0 | | |
| material icons | 1034.5 | 0 | | |
| npm | 1034.5 | 0 | | |
| free software directory | 1034.6 | 0 | | |
| frinkiac | 1034.7 | 0 | | |
| repology | 1034.7 | 0 | | |
| wolframalpha_api | 1034.7 | 0 | | |
| bing | 1034.8 | 0 | | |
| minecraft wiki | 1034.8 | 0 | | |
| steam | 1034.8 | 0 | | |
| wallhaven | 1034.8 | 0 | | |
| wikicommons.images | 1034.8 | 0 | | |
| swisscows news | 1034.9 | 0 | | |
| wikiversity | 1034.9 | 0 | | |
| yacy images | 1034.9 | 0 | | |
| flickr | 1035.0 | 0 | | |
| lemmy posts | 1035.0 | 0 | | |
| pixabay images | 1035.0 | 0 | | |
| bing images | 1035.1 | 0 | | |
| pypi | 1035.1 | 0 | | |
| habrahabr | 1035.2 | 0 | | |
| photon | 1035.2 | 0 | | |
| zapmeta | 1035.2 | 0 | | |
| hoogle | 1035.3 | 0 | | |
| lib.rs | 1035.3 | 0 | | |
| openclipart | 1035.3 | 0 | | |
| btdigg | 1035.4 | 0 | | |
| destatis | 1035.4 | 0 | | |
| openmeteo | 1035.4 | 0 | | |
| pinterest | 1035.4 | 0 | | |
| yandex | 1035.4 | 0 | | |
| devicons | 1035.5 | 0 | | |
| arch linux wiki | 1035.6 | 0 | | |
| yacy | 1035.6 | 0 | | |
| core.ac.uk | 1035.7 | 0 | | |
| deepl | 1035.7 | 0 | | |
| pi-hole.community | 1035.8 | 0 | | |
| presearch | 1035.8 | 0 | | |
| presearch images | 1035.8 | 0 | | |
| freesound | 1035.9 | 0 | | |
| mankier | 1035.9 | 0 | | |
| mastodon users | 1035.9 | 0 | | |
| pdbe | 1035.9 | 0 | | |
| torch | 1035.9 | 0 | | |
| ddg definitions | 1036.0 | 0 | | |
| hex | 1036.0 | 0 | | |
| il post | 1036.0 | 0 | | |
| sepiasearch | 1036.0 | 0 | | |
| wikiquote | 1036.0 | 0 | | |
| imgur | 1036.1 | 0 | | |
| wikipedia | 1036.1 | 0 | | |
| bpb | 1036.2 | 0 | | |
| adobe stock audio | 1036.3 | 0 | | |
| geizhals | 1036.3 | 0 | | |
| ollama | 1036.3 | 0 | | |
| adobe stock | 1036.4 | 0 | | |
| pkg.go.dev | 1036.4 | 0 | | |
| ina | 1036.5 | 0 | | |
| wikidata | 1036.5 | 0 | | |
| wikimini | 1036.5 | 0 | | |
| 360search | 1036.6 | 0 | | |
| astrophysics data system | 1036.6 | 0 | | |
| brave.news | 1036.6 | 0 | | |
| duden | 1036.7 | 0 | | |
| sogou | 1036.7 | 0 | | |
| wikibooks | 1036.7 | 0 | | |
| Torznab EZTV | 1036.8 | 0 | | |
| ahmia | 1036.8 | 0 | | |
| aol | 1036.8 | 0 | | |
| mojeek | 1036.8 | 0 | | |
| encyclosearch | 1036.9 | 0 | | |
| jisho | 1037.1 | 0 | | |
| searchmysite | 1037.1 | 0 | | |
| annas archive | 1037.2 | 0 | | |
| pexels | 1037.2 | 0 | | |
| wttr.in | 1037.2 | 0 | | |
| imdb | 1037.3 | 0 | | |
| crates.io | 1037.4 | 0 | | |
| deviantart | 1037.4 | 0 | | |
| qwant news | 1037.4 | 0 | | |
| crossref | 1037.5 | 0 | | |
| stackoverflow | 1037.6 | 0 | | |
| 500px | 1037.7 | 0 | | |
| mixcloud | 1037.7 | 0 | | |
| presearch news | 1037.7 | 0 | | |
| lucide | 1037.8 | 0 | | |
| boardreader | 1038.0 | 0 | | |
| seznam | 1038.0 | 0 | | |
| tokyotoshokan | 1038.0 | 0 | | |
| 9gag | 1038.1 | 0 | | |
| github code | 1038.3 | 0 | | |
| wiby | 1038.3 | 0 | | |
| pixiv | 1038.8 | 0 | | |
| selfhst icons | 1038.9 | 0 | | |
| huggingface spaces | 1039.1 | 0 | | |
| unsplash | 1039.3 | 0 | | |
| national vulnerability database | 1039.6 | 0 | | |
| brave.images | 1040.5 | 0 | | |
| erowid | 1040.6 | 0 | | |
| alpine linux packages | 1044.1 | 0 | | |
| senscritique | 1072.9 | 0 | | |
| pubmed | 1073.0 | 0 | | |
| wiktionary | 1073.0 | 0 | | |
| caddy.community | 1073.3 | 0 | | |
| cara | 1076.7 | 0 | | |
| anaconda | 1077.1 | 0 | | |
| kickass | 1077.1 | 0 | | |
| duckduckgo weather | 1077.2 | 0 | | |
| rubygems | 1077.4 | 0 | | |
| nyaa | 1077.5 | 0 | | |
| github | 1077.7 | 0 | | |
| cachy os packages | 1080.3 | 0 | | |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,255 @@
# SearXNG engine timing report
- Generated: 2026-06-09T05:24:36.248525+00:00
- Query: `OpenAI`
- Enabled engines tested: 244
- request_timeout: 6.0s
- max_request_timeout: 8.0s
- videos tab enabled: False
| Engine | Elapsed ms | Results | Unresponsive | Errors |
|---|---:|---:|---|---|
| gmx | 223.3 | 0 | ['gmx', 'Suspended: timeout'] | |
| brave | 432.9 | 0 | ['brave', 'Suspended: too many requests'] | |
| adobe stock audio | 472.4 | 0 | ['adobe stock audio', 'Suspended: access denied'] | |
| currency | 499.5 | 0 | | |
| sogou images | 646.9 | 48 | | |
| yacy | 666.8 | 0 | ['yacy', 'Suspended: timeout'] | |
| wikidata | 779.4 | 0 | ['wikidata', 'Suspended: timeout'] | |
| dictzone | 842.3 | 0 | | |
| yandex | 899.6 | 0 | ['yandex', 'Suspended: HTTP error'] | |
| bandcamp | 916.2 | 0 | | |
| unsplash | 1111.3 | 0 | ['unsplash', 'parsing error'] | |
| chefkoch | 1164.0 | 0 | | |
| rubygems | 1178.3 | 30 | | |
| baidu images | 1185.7 | 10 | | |
| bing | 1208.1 | 0 | ['bing', 'Suspended: HTTP connection error'] | |
| mozhi | 1284.4 | 0 | | |
| yacy images | 1324.7 | 0 | ['yacy images', 'Suspended: timeout'] | |
| qwant | 1339.0 | 0 | ['qwant', 'Suspended: timeout'] | |
| wikiquote | 1355.2 | 0 | ['wikiquote', 'Suspended: timeout'] | |
| moviepilot | 1387.3 | 0 | | |
| yandex images | 1390.4 | 0 | ['yandex images', 'Suspended: HTTP error'] | |
| brave.images | 1392.0 | 0 | ['brave.images', 'Suspended: too many requests'] | |
| mdn | 1451.7 | 10 | | |
| duckduckgo | 1555.5 | 0 | ['duckduckgo', 'CAPTCHA'] | |
| mastodon users | 1576.4 | 40 | | |
| photon | 1580.5 | 10 | | |
| lingva | 1599.4 | 0 | | |
| searchmysite | 1600.2 | 10 | | |
| mixcloud | 1744.6 | 0 | ['mixcloud', 'HTTP connection error'] | |
| quark | 1759.7 | 0 | ['quark', 'Suspended: CAPTCHA'] | |
| senscritique | 1809.2 | 16 | | |
| lemmy users | 1841.1 | 0 | ['lemmy users', 'Suspended: timeout'] | |
| mymemory translated | 1881.8 | 0 | | |
| arxiv | 1909.9 | 10 | | |
| pdbe | 1944.0 | 0 | | |
| pub.dev | 1953.6 | 10 | | |
| qwant images | 1956.7 | 0 | ['qwant images', 'Suspended: timeout'] | |
| wikipedia | 1975.9 | 0 | | |
| pypi | 1997.1 | 0 | | |
| yep | 2011.6 | 20 | | |
| destatis | 2051.3 | 0 | | |
| mojeek | 2067.4 | 0 | ['mojeek', 'Suspended: access denied'] | |
| fyyd | 2068.9 | 10 | | |
| bpb | 2086.7 | 15 | | |
| imdb | 2088.3 | 7 | | |
| pinterest | 2090.7 | 18 | | |
| wikicommons.images | 2100.3 | 10 | | |
| docker hub | 2104.9 | 10 | | |
| lucide | 2107.4 | 0 | | |
| tineye | 2109.2 | 0 | | |
| bing news | 2158.4 | 0 | | |
| steam | 2168.7 | 3 | | |
| gitlab | 2173.7 | 20 | | |
| uxwing | 2201.2 | 0 | ['uxwing', 'access denied'] | |
| crowdview | 2250.3 | 40 | | |
| ddg definitions | 2294.1 | 2 | | |
| superuser | 2312.2 | 8 | | |
| discuss.python | 2322.2 | 50 | | |
| aol | 2404.7 | 10 | | |
| selfhst icons | 2422.6 | 1 | | |
| naver | 2425.1 | 0 | | |
| openlibrary | 2427.1 | 0 | ['openlibrary', 'Suspended: timeout'] | |
| 500px | 2431.9 | 0 | ['500px', 'HTTP connection error'] | |
| naver news | 2451.5 | 10 | | |
| 9gag | 2474.7 | 0 | ['9gag', 'access denied'] | |
| wikispecies | 2476.0 | 5 | | |
| fynd | 2495.8 | 10 | | |
| sogou wechat | 2499.9 | 10 | | |
| mwmbl | 2519.2 | 34 | | |
| deezer | 2546.0 | 25 | | |
| bing images | 2550.4 | 0 | | |
| pixabay images | 2570.1 | 0 | ['pixabay images', 'parsing error'] | |
| huggingface spaces | 2584.2 | 1000 | | |
| imgur | 2586.0 | 39 | | |
| lemmy comments | 2593.3 | 0 | ['lemmy comments', 'Suspended: timeout'] | |
| baidu | 2597.9 | 10 | | |
| wikivoyage | 2634.2 | 0 | ['wikivoyage', 'Suspended: timeout'] | |
| hackernews | 2641.1 | 30 | | |
| radio browser | 2660.1 | 2 | | |
| wikicommons.audio | 2660.3 | 10 | | |
| jisho | 2675.7 | 1 | | |
| geizhals | 2692.5 | 0 | ['geizhals', 'access denied'] | |
| presearch images | 2705.2 | 100 | | |
| goodreads | 2718.2 | 0 | ['goodreads', 'parsing error'] | |
| huggingface datasets | 2741.9 | 736 | | |
| reddit | 2786.3 | 0 | ['reddit', 'access denied'] | |
| wikimini | 2837.7 | 0 | ['wikimini', 'Suspended: timeout'] | |
| semantic scholar | 2843.2 | 0 | ['semantic scholar', 'access denied'] | |
| askubuntu | 2859.7 | 10 | | |
| swisscows images | 2860.2 | 230 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| mojeek images | 2883.6 | 0 | ['mojeek images', 'access denied'] | |
| boardreader | 2914.1 | 10 | | |
| 360search | 2914.6 | 0 | ['360search', 'Suspended: timeout'] | |
| microsoft learn | 2989.8 | 10 | | |
| sogou | 2995.6 | 0 | ['sogou', 'Suspended: CAPTCHA'] | |
| presearch news | 3024.0 | 12 | | |
| rottentomatoes | 3034.3 | 20 | | |
| seznam | 3034.7 | 0 | ['seznam', 'Suspended: timeout'] | |
| flickr | 3062.2 | 25 | | |
| emojipedia | 3073.0 | 0 | ['emojipedia', 'access denied'] | |
| bt4g | 3076.5 | 0 | ['bt4g', 'HTTP connection error'] | |
| devicons | 3090.0 | 0 | | |
| braveapi | 3094.0 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| lobste.rs | 3102.7 | 20 | | |
| mankier | 3121.6 | 0 | | |
| baidu kaifa | 3149.4 | 10 | | |
| github | 3195.6 | 30 | | |
| adobe stock | 3205.4 | 0 | ['adobe stock', 'access denied'] | |
| findthatmeme | 3208.5 | 50 | | |
| flaticon | 3210.0 | 1 | | |
| gabanza | 3226.9 | 30 | | |
| pi-hole.community | 3231.0 | 4 | | |
| cachy os packages | 3246.0 | 10 | | |
| artic | 3262.9 | 20 | | |
| azure | 3337.2 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| wolframalpha | 3347.7 | 0 | ['wolframalpha', 'timeout'] | |
| 1337x | 3362.4 | 0 | ['1337x', 'access denied'] | |
| btdigg | 3386.7 | 0 | ['btdigg', 'too many requests'] | |
| minecraft wiki | 3400.6 | 5 | | |
| heexy | 3404.7 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| caddy.community | 3440.0 | 4 | | |
| zapmeta | 3457.0 | 0 | ['zapmeta', 'Suspended: access denied'] | |
| seekninja | 3465.1 | 229 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| annas archive | 3518.6 | 0 | ['annas archive', 'HTTP connection error'] | |
| duckduckgo images | 3525.0 | 95 | | |
| pkg.go.dev | 3575.8 | 50 | | |
| sepiasearch | 3617.9 | 10 | | |
| quark images | 3622.2 | 10 | | |
| hex | 3652.9 | 10 | | |
| il post | 3697.3 | 10 | | |
| nixos wiki | 3720.5 | 1 | | |
| brave.news | 3731.7 | 0 | | |
| huggingface | 3741.2 | 1000 | | |
| wikicommons.files | 3744.6 | 10 | | |
| ansa | 3770.4 | 12 | | |
| hoogle | 3784.8 | 25 | | |
| crates.io | 3789.5 | 10 | | |
| piratebay | 3849.7 | 35 | | |
| anaconda | 3862.4 | 0 | | |
| pexels | 3879.9 | 20 | | |
| material icons | 3893.7 | 0 | | |
| tagesschau | 3894.2 | 0 | ['tagesschau', 'Suspended: HTTP connection error'] | |
| wttr.in | 3899.2 | 0 | ['wttr.in', 'parsing error'] | |
| national vulnerability database | 3900.7 | 10 | | |
| naver images | 3932.4 | 0 | | |
| deviantart | 3998.8 | 0 | | |
| ollama | 4042.6 | 20 | | |
| encyclosearch | 4056.8 | 15 | | |
| lib.rs | 4128.2 | 0 | ['lib.rs', 'access denied'] | |
| deepl | 4132.6 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| openmeteo | 4135.8 | 0 | | |
| sourcehut | 4147.8 | 2 | | |
| openrepos | 4203.1 | 2 | | |
| lemmy posts | 4218.7 | 0 | ['lemmy posts', 'Suspended: timeout'] | |
| repology | 4226.1 | 236 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| nyaa | 4237.6 | 0 | | |
| packagist | 4242.1 | 15 | | |
| metacpan | 4261.6 | 0 | ['metacpan', 'HTTP error'] | |
| duckduckgo news | 4274.4 | 30 | | |
| presearch | 4327.9 | 14 | | |
| free software directory | 4395.2 | 0 | | |
| gentoo | 4425.4 | 0 | | |
| heexy images | 4442.7 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| swisscows | 4449.4 | 230 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| mastodon hashtags | 4489.6 | 40 | | |
| torch | 4490.0 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| crossref | 4506.0 | 18 | | |
| artstation | 4655.0 | 20 | | |
| frinkiac | 4668.4 | 0 | | |
| erowid | 4713.1 | 0 | | |
| woxikon.de synonyme | 4721.1 | 0 | ['woxikon.de synonyme', 'access denied'] | |
| apple app store | 4746.2 | 39 | | |
| springer nature | 4768.7 | 229 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| startpage | 4790.7 | 10 | | |
| soundcloud | 4814.0 | 9 | | |
| startpage news | 4817.2 | 0 | | |
| pubmed | 4827.6 | 20 | | |
| reuters | 4947.2 | 20 | | |
| wordnik | 4970.4 | 0 | | |
| apk mirror | 5026.0 | 10 | | |
| habrahabr | 5148.4 | 0 | | |
| github code | 5502.6 | 225 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| stackoverflow | 5504.1 | 10 | | |
| apple maps | 5522.6 | 0 | ['apple maps', 'HTTP error'] | |
| libretranslate | 5534.1 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| Torznab EZTV | 5610.6 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| core.ac.uk | 5651.0 | 191 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| mojeek news | 5679.8 | 0 | ['mojeek news', 'access denied'] | |
| npm | 5705.4 | 25 | | |
| duden | 5822.9 | 1 | | |
| library of congress | 5912.4 | 0 | ['library of congress', 'parsing error'] | |
| aol images | 6040.5 | 0 | ['aol images', 'HTTP error'] | |
| voidlinux | 6242.5 | 1 | | |
| alpine linux packages | 6474.4 | 0 | | |
| marginalia | 6480.6 | 192 | ['360search', 'Suspended: timeout'], ['aol', 'Suspended: HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx' | |
| ipernity | 6577.7 | 0 | ['ipernity', 'timeout'] | |
| gitea.com | 6647.2 | 10 | | |
| openstreetmap | 6784.4 | 2 | | |
| wikisource | 6810.9 | 0 | ['wikisource', 'timeout'] | |
| yandex music | 6882.8 | 0 | ['yandex music', 'HTTP error'] | |
| cara | 6951.4 | 24 | | |
| 1x | 6962.7 | 0 | | |
| startpage images | 7363.1 | 49 | | |
| duckduckgo weather | 7374.6 | 0 | ['duckduckgo weather', 'timeout'] | |
| wiby | 7413.0 | 0 | ['wiby', 'timeout'] | |
| swisscows news | 7434.2 | 230 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| wiktionary | 7442.0 | 0 | ['wiktionary', 'timeout'] | |
| genius | 7490.2 | 0 | ['genius', 'access denied'] | |
| wikibooks | 7744.2 | 0 | ['wikibooks', 'timeout'] | |
| openalex | 7759.0 | 10 | | |
| fdroid | 7872.9 | 0 | ['fdroid', 'timeout'] | |
| etymonline | 7893.8 | 0 | | |
| wikiversity | 8016.9 | 0 | ['wikiversity', 'Suspended: timeout'] | |
| flickr_api | 8020.6 | 281 | ['duckduckgo', 'CAPTCHA'], ['gmx', 'timeout'], ['openlibrary', 'timeout'], ['qwant', 'timeout'], ['seznam', 'timeout'], ['tagesschau', 'HTTP connection error'], ['wiby', 'timeout'] | |
| codeberg | 8024.5 | 0 | ['codeberg', 'timeout'] | |
| bitbucket | 8186.1 | 0 | | |
| openclipart | 8229.3 | 230 | ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary', 'Suspended: timeout'], | |
| public domain image archive | 8248.5 | 4 | | |
| wikinews | 8255.1 | 0 | ['wikinews', 'timeout'] | |
| wallhaven | 8320.1 | 211 | ['360search', 'Suspended: timeout'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek' | |
| lemmy communities | 8444.4 | 0 | ['lemmy communities', 'timeout'] | |
| freesound | 8760.3 | 263 | ['bing', 'HTTP connection error'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'access denied'], ['openlibrary', 'Suspended: timeout'], ['quark', 'CAPTCHA' | |
| openverse | 8833.7 | 0 | ['openverse', 'timeout'] | |
| tokyotoshokan | 9010.8 | 0 | ['tokyotoshokan', 'timeout'] | |
| astrophysics data system | 9129.3 | 246 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access denied'], ['op | |
| library genesis | 9175.3 | 0 | ['library genesis', 'timeout'] | |
| qwant news | 9213.3 | 0 | ['qwant news', 'timeout'] | |
| tootfinder | 9497.9 | 0 | ['tootfinder', 'timeout'] | |
| kickass | 9569.1 | 0 | ['kickass', 'timeout'] | |
| cloudflareai | 9714.5 | 201 | ['360search', 'Suspended: timeout'], ['aol', 'HTTP error'], ['baidu', 'Suspended: CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspende | |
| chinaso news | 9739.6 | 230 | ['360search', 'timeout'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary | |
| ina | 9839.0 | 0 | ['ina', 'timeout'] | |
| arch linux wiki | 9925.3 | 0 | | |
| grokipedia | 9985.0 | 211 | ['360search', 'Suspended: timeout'], ['baidu', 'CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspende | |
| chinaso images | 9985.5 | 211 | ['360search', 'Suspended: timeout'], ['baidu', 'CAPTCHA'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspende | |
| elasticsearch | 10129.5 | 230 | ['bing', 'Suspended: HTTP connection error'], ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access den | |
| pixiv | 10197.7 | 263 | ['bing', 'Suspended: HTTP connection error'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary', 'Suspended: timeout' | |
| openairedatasets | 10220.6 | 0 | ['openairedatasets', 'timeout'] | |
| ahmia | 10599.8 | 225 | ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary', 'timeout'], ['presearch', 'Suspend | |
| z-library | 10885.9 | 225 | ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'Suspended: timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary', 'Suspended: timeout'], | |
| wolframalpha_api | 10996.9 | 230 | ['brave', 'Suspended: too many requests'], ['duckduckgo', 'CAPTCHA'], ['gmx', 'timeout'], ['mojeek', 'Suspended: access denied'], ['openlibrary', 'timeout'], ['presearch', 'Suspend | |
| openairepublications | 11178.7 | 0 | ['openairepublications', 'timeout'] | |
| solidtorrents | 11261.0 | 0 | ['solidtorrents', 'timeout'] | |
| ebay | 12007.6 | 0 | | ReadTimeout: |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
# SearXNG final engine timing report
- Generated: 2026-06-09T05:29:38.995723+00:00
- Diagnostic query: `OpenAI`
- Enabled engines tested: 9
- request_timeout: 6.0s
- max_request_timeout: 8.0s
- tabs: general, news
| Engine | Shortcut | Elapsed ms | Results | Result engines | Unresponsive | Errors |
|---|---|---:|---:|---|---|---|
| 360search | 360so | 572.9 | 5 | 360search | [] | |
| crowdview | cv | 970.4 | 40 | crowdview | [] | |
| yep | yep | 1236.1 | 20 | yep | [] | |
| duckduckgo news | ddn | 1251.7 | 30 | duckduckgo news | [] | |
| naver news | nvrn | 1781.1 | 10 | naver news | [] | |
| searchmysite | sms | 2062.9 | 10 | searchmysite | [] | |
| mwmbl | mwm | 2474.9 | 34 | mwmbl | [] | |
| reuters | reu | 3044.4 | 20 | reuters | [] | |
| startpage | sp | 3639.6 | 10 | startpage | [] | |
## Default Search Export Summary
- Query: `OpenAI`
- Elapsed: 1351.8 ms
- Results: 118
- Result engines: 360search, crowdview, mwmbl, searchmysite, startpage, yep
- Unresponsive: []
@@ -0,0 +1,245 @@
engine,shortcut,backend,categories,configured_timeout,elapsed_ms,status_code,ok,result_count,unresponsive_engines,errors
duckduckgo news,ddn,duckduckgo_extra,"[""news""]",,1025.9,503,False,0,[],
bt4g,bt4g,bt4g,null,,1026.1,503,False,0,[],
openstreetmap,osm,openstreetmap,null,,1026.1,503,False,0,[],
wikisource,ws,mediawiki,"[""general"", ""wikimedia""]",,1026.3,503,False,0,[],
pub.dev,pd,xpath,"[""packages"", ""it""]",8.0,1027.5,503,False,0,[],
swisscows images,swi,swisscows,"""images""",,1027.5,503,False,0,[],
nixos wiki,nixw,mediawiki,"[""it"", ""software wikis""]",,1027.6,503,False,0,[],
flickr_api,fla,flickr,"""images""",,1027.9,503,False,0,[],
baidu images,bdi,baidu,"[""images""]",,1028.0,503,False,0,[],
voidlinux,void,voidlinux,null,,1028.1,503,False,0,[],
library genesis,lg,xpath,"""files""",8.0,1028.7,503,False,0,[],
mwmbl,mwm,mwmbl,null,,1028.7,503,False,0,[],
chinaso news,chinaso,chinaso,"[""news""]",,1029.3,503,False,0,[],
semantic scholar,se,semantic_scholar,null,,1029.4,503,False,0,[],
uxwing,ux,uxwing,null,,1029.4,503,False,0,[],
ansa,ans,ansa,null,,1029.5,503,False,0,[],
startpage,sp,startpage,"[""general"", ""web""]",,1029.6,503,False,0,[],
heexy,he,heexy,"""general""",,1029.7,503,False,0,[],
naver,nvr,naver,"[""general"", ""web""]",,1029.7,503,False,0,[],
askubuntu,ubuntu,stackexchange,"[""it"", ""q&a""]",,1029.8,503,False,0,[],
openalex,oa,openalex,null,8.0,1029.8,503,False,0,[],
mymemory translated,tl,translated,null,8.0,1029.9,503,False,0,[],
public domain image archive,pdia,public_domain_image_archive,null,,1029.9,503,False,0,[],
fynd,fynd,xpath,"""general""",,1030.0,503,False,0,[],
gentoo,ge,mediawiki,"[""it"", ""software wikis""]",8.0,1030.0,503,False,0,[],
reuters,reu,reuters,null,,1030.1,503,False,0,[],
heexy images,hei,heexy,"""images""",,1030.2,503,False,0,[],
hackernews,hn,hackernews,null,,1030.3,503,False,0,[],
huggingface,hf,huggingface,null,,1030.3,503,False,0,[],
mojeek images,mjkimg,mojeek,"[""images"", ""web""]",,1030.3,503,False,0,[],
openairedatasets,oad,json_engine,"""science""",8.0,1030.3,503,False,0,[],
codeberg,cb,gitea,null,,1030.4,503,False,0,[],
goodreads,good,goodreads,null,8.0,1030.4,503,False,0,[],
deezer,dz,deezer,null,,1030.5,503,False,0,[],
gitea.com,gitea,gitea,null,,1030.5,503,False,0,[],
lingva,lv,lingva,null,8.0,1030.5,503,False,0,[],
flaticon,fli,flaticon,null,,1030.6,503,False,0,[],
huggingface datasets,hfd,huggingface,null,,1030.7,503,False,0,[],
ebay,eb,ebay,null,5,1030.8,503,False,0,[],
radio browser,rb,radio_browser,null,,1030.8,503,False,0,[],
artic,arc,artic,null,8.0,1030.9,503,False,0,[],
soundcloud,sc,soundcloud,null,,1030.9,503,False,0,[],
wikivoyage,wy,mediawiki,"[""general"", ""wikimedia""]",,1031.0,503,False,0,[],
1337x,1337x,1337x,null,,1031.1,503,False,0,[],
bandcamp,bc,bandcamp,"""music""",,1031.1,503,False,0,[],
qwant images,qwi,qwant,"[""images"", ""web""]",,1031.1,503,False,0,[],
tagesschau,ts,tagesschau,null,,1031.1,503,False,0,[],
z-library,zlib,zlibrary,null,8.0,1031.2,503,False,0,[],
fyyd,fy,fyyd,null,8.0,1031.3,503,False,0,[],
apple maps,apm,apple_maps,null,8.0,1031.4,503,False,0,[],
tootfinder,toot,tootfinder,null,,1031.4,503,False,0,[],
wikinews,wn,mediawiki,"[""news"", ""wikimedia""]",,1031.4,503,False,0,[],
superuser,su,stackexchange,"[""it"", ""q&a""]",,1031.5,503,False,0,[],
etymonline,et,xpath,"[""dictionaries""]",,1031.6,503,False,0,[],
crowdview,cv,json_engine,"""general""",,1031.8,503,False,0,[],
lobste.rs,lo,xpath,"""it""",8.0,1031.9,503,False,0,[],
wikicommons.audio,wca,wikicommons,"""music""",,1031.9,503,False,0,[],
mozhi,mz,mozhi,null,8.0,1032.0,503,False,0,[],
artstation,as,artstation,"""images""",,1032.1,503,False,0,[],
duckduckgo,ddg,duckduckgo,null,,1032.1,503,False,0,[],
quark,qk,quark,"[""general""]",,1032.1,503,False,0,[],
apk mirror,apkm,apkmirror,null,8.0,1032.2,503,False,0,[],
genius,gen,genius,null,,1032.2,503,False,0,[],
moviepilot,mp,moviepilot,null,,1032.2,503,False,0,[],
dictzone,dc,dictzone,null,,1032.3,503,False,0,[],
library of congress,loc,loc,"""images""",,1032.3,503,False,0,[],
naver images,nvri,naver,"[""images""]",,1032.3,503,False,0,[],
packagist,pack,json_engine,"[""it"", ""packages""]",8.0,1032.3,503,False,0,[],
gabanza,gab,xpath,null,4,1032.4,503,False,0,[],
lemmy comments,lecom,lemmy,null,,1032.4,503,False,0,[],
microsoft learn,msl,microsoft_learn,null,,1032.4,503,False,0,[],
fdroid,fd,fdroid,null,,1032.5,503,False,0,[],
mojeek news,mjknews,mojeek,"[""news"", ""web""]",,1032.5,503,False,0,[],
ipernity,ip,ipernity,null,,1032.6,503,False,0,[],
sogou wechat,sogouw,sogou_wechat,null,,1032.6,503,False,0,[],
chefkoch,chef,chefkoch,null,,1032.7,503,False,0,[],
duckduckgo images,ddi,duckduckgo_extra,"[""images""]",,1032.7,503,False,0,[],
yep,yep,yep,"""general""",,1032.7,503,False,0,[],
apple app store,aps,apple_app_store,null,,1032.8,503,False,0,[],
bitbucket,bb,xpath,"[""it"", ""repos""]",8.0,1032.8,503,False,0,[],
reddit,re,reddit,null,,1032.8,503,False,0,[],
aol images,aoli,aol,"[""images""]",,1032.9,503,False,0,[],
arxiv,arx,arxiv,null,,1032.9,503,False,0,[],
chinaso images,chinasoi,chinaso,"[""images""]",,1032.9,503,False,0,[],
metacpan,cpan,metacpan,null,,1032.9,503,False,0,[],
swisscows,sw,swisscows,"""general""",,1032.9,503,False,0,[],
bing news,bin,bing_news,null,,1033.0,503,False,0,[],
libretranslate,lt,libretranslate,null,,1033.0,503,False,0,[],
wikispecies,wsp,mediawiki,"[""general"", ""science"", ""wikimedia""]",,1033.0,503,False,0,[],
baidu,bd,baidu,"[""general""]",,1033.1,503,False,0,[],
brave,br,brave,"[""general"", ""web""]",,1033.1,503,False,0,[],
quark images,qki,quark,"[""images""]",,1033.1,503,False,0,[],
sogou images,sogoui,sogou_images,null,,1033.1,503,False,0,[],
azure,az,azure,"[""it"", ""cloud""]",,1033.2,503,False,0,[],
braveapi,,braveapi,null,,1033.2,503,False,0,[],
discuss.python,dpy,discourse,"[""it"", ""q&a""]",,1033.2,503,False,0,[],
springer nature,springer,springer,null,5,1033.2,503,False,0,[],
findthatmeme,ftm,findthatmeme,null,,1033.3,503,False,0,[],
lemmy communities,leco,lemmy,null,,1033.3,503,False,0,[],
solidtorrents,solid,solidtorrents,null,8.0,1033.3,503,False,0,[],
startpage news,spn,startpage,"[""news"", ""web""]",,1033.3,503,False,0,[],
1x,1x,www1x,null,8.0,1033.4,503,False,0,[],
mdn,mdn,json_engine,"[""it""]",,1033.4,503,False,0,[],
openrepos,or,xpath,"""files""",8.0,1033.4,503,False,0,[],
sourcehut,srht,sourcehut,null,,1033.4,503,False,0,[],
yandex music,ydm,yandex_music,null,,1033.4,503,False,0,[],
cloudflareai,cfai,cloudflareai,null,8.0,1033.5,503,False,0,[],
gitlab,gl,gitlab,null,,1033.5,503,False,0,[],
openairepublications,oap,json_engine,"""science""",8.0,1033.5,503,False,0,[],
yandex images,ydi,yandex,"""images""",,1033.5,503,False,0,[],
currency,cc,currency_convert,null,,1033.6,503,False,0,[],
tineye,tin,tineye,null,8.0,1033.6,503,False,0,[],
docker hub,dh,docker_hub,"[""it"", ""packages""]",,1033.7,503,False,0,[],
grokipedia,gp,grokipedia,null,,1033.7,503,False,0,[],
wolframalpha,wa,wolframalpha_noapi,"""general""",8.0,1033.7,503,False,0,[],
openverse,opv,openverse,"""images""",,1033.8,503,False,0,[],
woxikon.de synonyme,woxi,xpath,"[""dictionaries""]",8.0,1033.8,503,False,0,[],
qwant,qw,qwant,"[""general"", ""web""]",,1033.9,503,False,0,[],
wordnik,wnik,wordnik,null,8.0,1033.9,503,False,0,[],
piratebay,tpb,piratebay,null,8.0,1034.0,503,False,0,[],
lemmy users,leus,lemmy,null,,1034.1,503,False,0,[],
seekninja,sen,seekninja,null,8.0,1034.1,503,False,0,[],
startpage images,spi,startpage,"[""images"", ""web""]",,1034.1,503,False,0,[],
openlibrary,ol,openlibrary,null,8.0,1034.2,503,False,0,[],
wikicommons.files,wcf,wikicommons,"""files""",,1034.2,503,False,0,[],
rottentomatoes,rt,rottentomatoes,null,,1034.3,503,False,0,[],
emojipedia,em,emojipedia,null,8.0,1034.4,503,False,0,[],
marginalia,mar,marginalia,null,,1034.4,503,False,0,[],
naver news,nvrn,naver,"[""news""]",,1034.4,503,False,0,[],
baidu kaifa,bdk,baidu,"[""it""]",,1034.5,503,False,0,[],
elasticsearch,els,elasticsearch,null,,1034.5,503,False,0,[],
gmx,gmx,gmx,null,,1034.5,503,False,0,[],
mastodon hashtags,mah,mastodon,null,,1034.5,503,False,0,[],
material icons,mi,material_icons,null,,1034.5,503,False,0,[],
npm,npm,npm,null,8.0,1034.5,503,False,0,[],
free software directory,fsd,mediawiki,"[""it"", ""software wikis""]",8.0,1034.6,503,False,0,[],
frinkiac,frk,frinkiac,null,,1034.7,503,False,0,[],
repology,rep,repology,null,,1034.7,503,False,0,[],
wolframalpha_api,waa,wolframalpha_api,"""general""",8.0,1034.7,503,False,0,[],
bing,bi,bing,null,,1034.8,503,False,0,[],
minecraft wiki,mcw,mediawiki,"[""software wikis""]",,1034.8,503,False,0,[],
steam,stm,steam,null,,1034.8,503,False,0,[],
wallhaven,wh,wallhaven,null,,1034.8,503,False,0,[],
wikicommons.images,wci,wikicommons,"""images""",,1034.8,503,False,0,[],
swisscows news,swn,swisscows_news,null,,1034.9,503,False,0,[],
wikiversity,wv,mediawiki,"[""general"", ""wikimedia""]",,1034.9,503,False,0,[],
yacy images,yai,yacy,"""images""",8.0,1034.9,503,False,0,[],
flickr,fl,flickr_noapi,"""images""",,1035.0,503,False,0,[],
lemmy posts,lepo,lemmy,null,,1035.0,503,False,0,[],
pixabay images,pixi,pixabay,"""images""",,1035.0,503,False,0,[],
bing images,bii,bing_images,null,,1035.1,503,False,0,[],
pypi,pypi,pypi,null,,1035.1,503,False,0,[],
habrahabr,habr,xpath,"""it""",8.0,1035.2,503,False,0,[],
photon,ph,photon,null,,1035.2,503,False,0,[],
zapmeta,zpm,xpath,null,,1035.2,503,False,0,[],
hoogle,ho,xpath,"[""it"", ""packages""]",,1035.3,503,False,0,[],
lib.rs,lrs,lib_rs,null,,1035.3,503,False,0,[],
openclipart,ocl,openclipart,null,8.0,1035.3,503,False,0,[],
btdigg,bt,btdigg,null,,1035.4,503,False,0,[],
destatis,destat,destatis,null,,1035.4,503,False,0,[],
openmeteo,om,open_meteo,null,,1035.4,503,False,0,[],
pinterest,pin,pinterest,null,,1035.4,503,False,0,[],
yandex,yd,yandex,"""general""",,1035.4,503,False,0,[],
devicons,di,devicons,null,8.0,1035.5,503,False,0,[],
arch linux wiki,al,archlinux,null,,1035.6,503,False,0,[],
yacy,ya,yacy,"""general""",8.0,1035.6,503,False,0,[],
core.ac.uk,cor,core,null,,1035.7,503,False,0,[],
deepl,dpl,deepl,null,8.0,1035.7,503,False,0,[],
pi-hole.community,pi,discourse,"[""it"", ""q&a""]",,1035.8,503,False,0,[],
presearch,ps,presearch,"[""general"", ""web""]",8.0,1035.8,503,False,0,[],
presearch images,psimg,presearch,"[""images"", ""web""]",8.0,1035.8,503,False,0,[],
freesound,fnd,freesound,null,8.0,1035.9,503,False,0,[],
mankier,man,json_engine,"""it""",,1035.9,503,False,0,[],
mastodon users,mau,mastodon,null,,1035.9,503,False,0,[],
pdbe,pdb,pdbe,null,,1035.9,503,False,0,[],
torch,tch,xpath,"""onions""",,1035.9,503,False,0,[],
ddg definitions,ddd,duckduckgo_definitions,null,,1036.0,503,False,0,[],
hex,hex,hex,null,,1036.0,503,False,0,[],
il post,pst,il_post,null,,1036.0,503,False,0,[],
sepiasearch,sep,sepiasearch,null,,1036.0,503,False,0,[],
wikiquote,wq,mediawiki,"[""general"", ""wikimedia""]",,1036.0,503,False,0,[],
imgur,img,imgur,null,,1036.1,503,False,0,[],
wikipedia,wp,wikipedia,"[""general""]",,1036.1,503,False,0,[],
bpb,bpb,bpb,null,,1036.2,503,False,0,[],
adobe stock audio,asa,adobe_stock,"[""music""]",6,1036.3,503,False,0,[],
geizhals,geiz,geizhals,null,,1036.3,503,False,0,[],
ollama,ollama,ollama,null,,1036.3,503,False,0,[],
adobe stock,asi,adobe_stock,"[""images""]",6,1036.4,503,False,0,[],
pkg.go.dev,pgo,pkg_go_dev,null,,1036.4,503,False,0,[],
ina,in,ina,null,8.0,1036.5,503,False,0,[],
wikidata,wd,wikidata,"[""general""]",8.0,1036.5,503,False,0,[],
wikimini,wkmn,xpath,"""general""",,1036.5,503,False,0,[],
360search,360so,360search,null,8.0,1036.6,503,False,0,[],
astrophysics data system,ads,astrophysics_data_system,null,,1036.6,503,False,0,[],
brave.news,brnews,brave,"""news""",,1036.6,503,False,0,[],
duden,du,duden,null,,1036.7,503,False,0,[],
sogou,sogou,sogou,null,,1036.7,503,False,0,[],
wikibooks,wb,mediawiki,"[""general"", ""wikimedia""]",,1036.7,503,False,0,[],
Torznab EZTV,eztv,torznab,null,,1036.8,503,False,0,[],
ahmia,ah,ahmia,"""onions""",8.0,1036.8,503,False,0,[],
aol,aol,aol,"[""general""]",,1036.8,503,False,0,[],
mojeek,mjk,mojeek,"[""general"", ""web""]",,1036.8,503,False,0,[],
encyclosearch,es,json_engine,"""general""",,1036.9,503,False,0,[],
jisho,js,jisho,null,8.0,1037.1,503,False,0,[],
searchmysite,sms,xpath,"""general""",,1037.1,503,False,0,[],
annas archive,aa,annas_archive,null,5,1037.2,503,False,0,[],
pexels,pe,pexels,null,,1037.2,503,False,0,[],
wttr.in,wttr,wttr,null,8.0,1037.2,503,False,0,[],
imdb,imdb,imdb,null,8.0,1037.3,503,False,0,[],
crates.io,crates,crates,null,8.0,1037.4,503,False,0,[],
deviantart,da,deviantart,null,8.0,1037.4,503,False,0,[],
qwant news,qwn,qwant,"""news""",,1037.4,503,False,0,[],
crossref,cr,crossref,null,8.0,1037.5,503,False,0,[],
stackoverflow,st,stackexchange,"[""it"", ""q&a""]",,1037.6,503,False,0,[],
500px,500,500px,null,5,1037.7,503,False,0,[],
mixcloud,mc,mixcloud,null,,1037.7,503,False,0,[],
presearch news,psnews,presearch,"[""news"", ""web""]",8.0,1037.7,503,False,0,[],
lucide,luc,lucide,null,8.0,1037.8,503,False,0,[],
boardreader,boa,boardreader,null,,1038.0,503,False,0,[],
seznam,szn,seznam,null,,1038.0,503,False,0,[],
tokyotoshokan,tt,tokyotoshokan,null,8.0,1038.0,503,False,0,[],
9gag,9g,9gag,null,,1038.1,503,False,0,[],
github code,ghc,github_code,null,8.0,1038.3,503,False,0,[],
wiby,wib,json_engine,"[""general"", ""web""]",,1038.3,503,False,0,[],
pixiv,pv,pixiv,null,,1038.8,503,False,0,[],
selfhst icons,si,selfhst,null,,1038.9,503,False,0,[],
huggingface spaces,hfs,huggingface,null,,1039.1,503,False,0,[],
unsplash,us,unsplash,null,,1039.3,503,False,0,[],
national vulnerability database,nvd,nvd,null,,1039.6,503,False,0,[],
brave.images,brimg,brave,"[""images"", ""web""]",,1040.5,503,False,0,[],
erowid,ew,xpath,[],,1040.6,503,False,0,[],
alpine linux packages,alp,alpinelinux,null,,1044.1,503,False,0,[],
senscritique,scr,senscritique,null,8.0,1072.9,503,False,0,[],
pubmed,pub,pubmed,null,,1073.0,503,False,0,[],
wiktionary,wt,mediawiki,"[""dictionaries"", ""wikimedia""]",,1073.0,503,False,0,[],
caddy.community,caddy,discourse,"[""it"", ""q&a""]",,1073.3,503,False,0,[],
cara,ca,cara,null,,1076.7,503,False,0,[],
anaconda,conda,xpath,"""it""",8.0,1077.1,503,False,0,[],
kickass,kc,kickass,null,8.0,1077.1,503,False,0,[],
duckduckgo weather,ddw,duckduckgo_weather,null,,1077.2,503,False,0,[],
rubygems,rbg,xpath,"[""it"", ""packages""]",,1077.4,503,False,0,[],
nyaa,nt,nyaa,null,,1077.5,503,False,0,[],
github,gh,github,null,,1077.7,503,False,0,[],
cachy os packages,cos,cachy_os,null,,1080.3,503,False,0,[],
1 engine shortcut backend categories configured_timeout elapsed_ms status_code ok result_count unresponsive_engines errors
2 duckduckgo news ddn duckduckgo_extra ["news"] 1025.9 503 False 0 []
3 bt4g bt4g bt4g null 1026.1 503 False 0 []
4 openstreetmap osm openstreetmap null 1026.1 503 False 0 []
5 wikisource ws mediawiki ["general", "wikimedia"] 1026.3 503 False 0 []
6 pub.dev pd xpath ["packages", "it"] 8.0 1027.5 503 False 0 []
7 swisscows images swi swisscows "images" 1027.5 503 False 0 []
8 nixos wiki nixw mediawiki ["it", "software wikis"] 1027.6 503 False 0 []
9 flickr_api fla flickr "images" 1027.9 503 False 0 []
10 baidu images bdi baidu ["images"] 1028.0 503 False 0 []
11 voidlinux void voidlinux null 1028.1 503 False 0 []
12 library genesis lg xpath "files" 8.0 1028.7 503 False 0 []
13 mwmbl mwm mwmbl null 1028.7 503 False 0 []
14 chinaso news chinaso chinaso ["news"] 1029.3 503 False 0 []
15 semantic scholar se semantic_scholar null 1029.4 503 False 0 []
16 uxwing ux uxwing null 1029.4 503 False 0 []
17 ansa ans ansa null 1029.5 503 False 0 []
18 startpage sp startpage ["general", "web"] 1029.6 503 False 0 []
19 heexy he heexy "general" 1029.7 503 False 0 []
20 naver nvr naver ["general", "web"] 1029.7 503 False 0 []
21 askubuntu ubuntu stackexchange ["it", "q&a"] 1029.8 503 False 0 []
22 openalex oa openalex null 8.0 1029.8 503 False 0 []
23 mymemory translated tl translated null 8.0 1029.9 503 False 0 []
24 public domain image archive pdia public_domain_image_archive null 1029.9 503 False 0 []
25 fynd fynd xpath "general" 1030.0 503 False 0 []
26 gentoo ge mediawiki ["it", "software wikis"] 8.0 1030.0 503 False 0 []
27 reuters reu reuters null 1030.1 503 False 0 []
28 heexy images hei heexy "images" 1030.2 503 False 0 []
29 hackernews hn hackernews null 1030.3 503 False 0 []
30 huggingface hf huggingface null 1030.3 503 False 0 []
31 mojeek images mjkimg mojeek ["images", "web"] 1030.3 503 False 0 []
32 openairedatasets oad json_engine "science" 8.0 1030.3 503 False 0 []
33 codeberg cb gitea null 1030.4 503 False 0 []
34 goodreads good goodreads null 8.0 1030.4 503 False 0 []
35 deezer dz deezer null 1030.5 503 False 0 []
36 gitea.com gitea gitea null 1030.5 503 False 0 []
37 lingva lv lingva null 8.0 1030.5 503 False 0 []
38 flaticon fli flaticon null 1030.6 503 False 0 []
39 huggingface datasets hfd huggingface null 1030.7 503 False 0 []
40 ebay eb ebay null 5 1030.8 503 False 0 []
41 radio browser rb radio_browser null 1030.8 503 False 0 []
42 artic arc artic null 8.0 1030.9 503 False 0 []
43 soundcloud sc soundcloud null 1030.9 503 False 0 []
44 wikivoyage wy mediawiki ["general", "wikimedia"] 1031.0 503 False 0 []
45 1337x 1337x 1337x null 1031.1 503 False 0 []
46 bandcamp bc bandcamp "music" 1031.1 503 False 0 []
47 qwant images qwi qwant ["images", "web"] 1031.1 503 False 0 []
48 tagesschau ts tagesschau null 1031.1 503 False 0 []
49 z-library zlib zlibrary null 8.0 1031.2 503 False 0 []
50 fyyd fy fyyd null 8.0 1031.3 503 False 0 []
51 apple maps apm apple_maps null 8.0 1031.4 503 False 0 []
52 tootfinder toot tootfinder null 1031.4 503 False 0 []
53 wikinews wn mediawiki ["news", "wikimedia"] 1031.4 503 False 0 []
54 superuser su stackexchange ["it", "q&a"] 1031.5 503 False 0 []
55 etymonline et xpath ["dictionaries"] 1031.6 503 False 0 []
56 crowdview cv json_engine "general" 1031.8 503 False 0 []
57 lobste.rs lo xpath "it" 8.0 1031.9 503 False 0 []
58 wikicommons.audio wca wikicommons "music" 1031.9 503 False 0 []
59 mozhi mz mozhi null 8.0 1032.0 503 False 0 []
60 artstation as artstation "images" 1032.1 503 False 0 []
61 duckduckgo ddg duckduckgo null 1032.1 503 False 0 []
62 quark qk quark ["general"] 1032.1 503 False 0 []
63 apk mirror apkm apkmirror null 8.0 1032.2 503 False 0 []
64 genius gen genius null 1032.2 503 False 0 []
65 moviepilot mp moviepilot null 1032.2 503 False 0 []
66 dictzone dc dictzone null 1032.3 503 False 0 []
67 library of congress loc loc "images" 1032.3 503 False 0 []
68 naver images nvri naver ["images"] 1032.3 503 False 0 []
69 packagist pack json_engine ["it", "packages"] 8.0 1032.3 503 False 0 []
70 gabanza gab xpath null 4 1032.4 503 False 0 []
71 lemmy comments lecom lemmy null 1032.4 503 False 0 []
72 microsoft learn msl microsoft_learn null 1032.4 503 False 0 []
73 fdroid fd fdroid null 1032.5 503 False 0 []
74 mojeek news mjknews mojeek ["news", "web"] 1032.5 503 False 0 []
75 ipernity ip ipernity null 1032.6 503 False 0 []
76 sogou wechat sogouw sogou_wechat null 1032.6 503 False 0 []
77 chefkoch chef chefkoch null 1032.7 503 False 0 []
78 duckduckgo images ddi duckduckgo_extra ["images"] 1032.7 503 False 0 []
79 yep yep yep "general" 1032.7 503 False 0 []
80 apple app store aps apple_app_store null 1032.8 503 False 0 []
81 bitbucket bb xpath ["it", "repos"] 8.0 1032.8 503 False 0 []
82 reddit re reddit null 1032.8 503 False 0 []
83 aol images aoli aol ["images"] 1032.9 503 False 0 []
84 arxiv arx arxiv null 1032.9 503 False 0 []
85 chinaso images chinasoi chinaso ["images"] 1032.9 503 False 0 []
86 metacpan cpan metacpan null 1032.9 503 False 0 []
87 swisscows sw swisscows "general" 1032.9 503 False 0 []
88 bing news bin bing_news null 1033.0 503 False 0 []
89 libretranslate lt libretranslate null 1033.0 503 False 0 []
90 wikispecies wsp mediawiki ["general", "science", "wikimedia"] 1033.0 503 False 0 []
91 baidu bd baidu ["general"] 1033.1 503 False 0 []
92 brave br brave ["general", "web"] 1033.1 503 False 0 []
93 quark images qki quark ["images"] 1033.1 503 False 0 []
94 sogou images sogoui sogou_images null 1033.1 503 False 0 []
95 azure az azure ["it", "cloud"] 1033.2 503 False 0 []
96 braveapi braveapi null 1033.2 503 False 0 []
97 discuss.python dpy discourse ["it", "q&a"] 1033.2 503 False 0 []
98 springer nature springer springer null 5 1033.2 503 False 0 []
99 findthatmeme ftm findthatmeme null 1033.3 503 False 0 []
100 lemmy communities leco lemmy null 1033.3 503 False 0 []
101 solidtorrents solid solidtorrents null 8.0 1033.3 503 False 0 []
102 startpage news spn startpage ["news", "web"] 1033.3 503 False 0 []
103 1x 1x www1x null 8.0 1033.4 503 False 0 []
104 mdn mdn json_engine ["it"] 1033.4 503 False 0 []
105 openrepos or xpath "files" 8.0 1033.4 503 False 0 []
106 sourcehut srht sourcehut null 1033.4 503 False 0 []
107 yandex music ydm yandex_music null 1033.4 503 False 0 []
108 cloudflareai cfai cloudflareai null 8.0 1033.5 503 False 0 []
109 gitlab gl gitlab null 1033.5 503 False 0 []
110 openairepublications oap json_engine "science" 8.0 1033.5 503 False 0 []
111 yandex images ydi yandex "images" 1033.5 503 False 0 []
112 currency cc currency_convert null 1033.6 503 False 0 []
113 tineye tin tineye null 8.0 1033.6 503 False 0 []
114 docker hub dh docker_hub ["it", "packages"] 1033.7 503 False 0 []
115 grokipedia gp grokipedia null 1033.7 503 False 0 []
116 wolframalpha wa wolframalpha_noapi "general" 8.0 1033.7 503 False 0 []
117 openverse opv openverse "images" 1033.8 503 False 0 []
118 woxikon.de synonyme woxi xpath ["dictionaries"] 8.0 1033.8 503 False 0 []
119 qwant qw qwant ["general", "web"] 1033.9 503 False 0 []
120 wordnik wnik wordnik null 8.0 1033.9 503 False 0 []
121 piratebay tpb piratebay null 8.0 1034.0 503 False 0 []
122 lemmy users leus lemmy null 1034.1 503 False 0 []
123 seekninja sen seekninja null 8.0 1034.1 503 False 0 []
124 startpage images spi startpage ["images", "web"] 1034.1 503 False 0 []
125 openlibrary ol openlibrary null 8.0 1034.2 503 False 0 []
126 wikicommons.files wcf wikicommons "files" 1034.2 503 False 0 []
127 rottentomatoes rt rottentomatoes null 1034.3 503 False 0 []
128 emojipedia em emojipedia null 8.0 1034.4 503 False 0 []
129 marginalia mar marginalia null 1034.4 503 False 0 []
130 naver news nvrn naver ["news"] 1034.4 503 False 0 []
131 baidu kaifa bdk baidu ["it"] 1034.5 503 False 0 []
132 elasticsearch els elasticsearch null 1034.5 503 False 0 []
133 gmx gmx gmx null 1034.5 503 False 0 []
134 mastodon hashtags mah mastodon null 1034.5 503 False 0 []
135 material icons mi material_icons null 1034.5 503 False 0 []
136 npm npm npm null 8.0 1034.5 503 False 0 []
137 free software directory fsd mediawiki ["it", "software wikis"] 8.0 1034.6 503 False 0 []
138 frinkiac frk frinkiac null 1034.7 503 False 0 []
139 repology rep repology null 1034.7 503 False 0 []
140 wolframalpha_api waa wolframalpha_api "general" 8.0 1034.7 503 False 0 []
141 bing bi bing null 1034.8 503 False 0 []
142 minecraft wiki mcw mediawiki ["software wikis"] 1034.8 503 False 0 []
143 steam stm steam null 1034.8 503 False 0 []
144 wallhaven wh wallhaven null 1034.8 503 False 0 []
145 wikicommons.images wci wikicommons "images" 1034.8 503 False 0 []
146 swisscows news swn swisscows_news null 1034.9 503 False 0 []
147 wikiversity wv mediawiki ["general", "wikimedia"] 1034.9 503 False 0 []
148 yacy images yai yacy "images" 8.0 1034.9 503 False 0 []
149 flickr fl flickr_noapi "images" 1035.0 503 False 0 []
150 lemmy posts lepo lemmy null 1035.0 503 False 0 []
151 pixabay images pixi pixabay "images" 1035.0 503 False 0 []
152 bing images bii bing_images null 1035.1 503 False 0 []
153 pypi pypi pypi null 1035.1 503 False 0 []
154 habrahabr habr xpath "it" 8.0 1035.2 503 False 0 []
155 photon ph photon null 1035.2 503 False 0 []
156 zapmeta zpm xpath null 1035.2 503 False 0 []
157 hoogle ho xpath ["it", "packages"] 1035.3 503 False 0 []
158 lib.rs lrs lib_rs null 1035.3 503 False 0 []
159 openclipart ocl openclipart null 8.0 1035.3 503 False 0 []
160 btdigg bt btdigg null 1035.4 503 False 0 []
161 destatis destat destatis null 1035.4 503 False 0 []
162 openmeteo om open_meteo null 1035.4 503 False 0 []
163 pinterest pin pinterest null 1035.4 503 False 0 []
164 yandex yd yandex "general" 1035.4 503 False 0 []
165 devicons di devicons null 8.0 1035.5 503 False 0 []
166 arch linux wiki al archlinux null 1035.6 503 False 0 []
167 yacy ya yacy "general" 8.0 1035.6 503 False 0 []
168 core.ac.uk cor core null 1035.7 503 False 0 []
169 deepl dpl deepl null 8.0 1035.7 503 False 0 []
170 pi-hole.community pi discourse ["it", "q&a"] 1035.8 503 False 0 []
171 presearch ps presearch ["general", "web"] 8.0 1035.8 503 False 0 []
172 presearch images psimg presearch ["images", "web"] 8.0 1035.8 503 False 0 []
173 freesound fnd freesound null 8.0 1035.9 503 False 0 []
174 mankier man json_engine "it" 1035.9 503 False 0 []
175 mastodon users mau mastodon null 1035.9 503 False 0 []
176 pdbe pdb pdbe null 1035.9 503 False 0 []
177 torch tch xpath "onions" 1035.9 503 False 0 []
178 ddg definitions ddd duckduckgo_definitions null 1036.0 503 False 0 []
179 hex hex hex null 1036.0 503 False 0 []
180 il post pst il_post null 1036.0 503 False 0 []
181 sepiasearch sep sepiasearch null 1036.0 503 False 0 []
182 wikiquote wq mediawiki ["general", "wikimedia"] 1036.0 503 False 0 []
183 imgur img imgur null 1036.1 503 False 0 []
184 wikipedia wp wikipedia ["general"] 1036.1 503 False 0 []
185 bpb bpb bpb null 1036.2 503 False 0 []
186 adobe stock audio asa adobe_stock ["music"] 6 1036.3 503 False 0 []
187 geizhals geiz geizhals null 1036.3 503 False 0 []
188 ollama ollama ollama null 1036.3 503 False 0 []
189 adobe stock asi adobe_stock ["images"] 6 1036.4 503 False 0 []
190 pkg.go.dev pgo pkg_go_dev null 1036.4 503 False 0 []
191 ina in ina null 8.0 1036.5 503 False 0 []
192 wikidata wd wikidata ["general"] 8.0 1036.5 503 False 0 []
193 wikimini wkmn xpath "general" 1036.5 503 False 0 []
194 360search 360so 360search null 8.0 1036.6 503 False 0 []
195 astrophysics data system ads astrophysics_data_system null 1036.6 503 False 0 []
196 brave.news brnews brave "news" 1036.6 503 False 0 []
197 duden du duden null 1036.7 503 False 0 []
198 sogou sogou sogou null 1036.7 503 False 0 []
199 wikibooks wb mediawiki ["general", "wikimedia"] 1036.7 503 False 0 []
200 Torznab EZTV eztv torznab null 1036.8 503 False 0 []
201 ahmia ah ahmia "onions" 8.0 1036.8 503 False 0 []
202 aol aol aol ["general"] 1036.8 503 False 0 []
203 mojeek mjk mojeek ["general", "web"] 1036.8 503 False 0 []
204 encyclosearch es json_engine "general" 1036.9 503 False 0 []
205 jisho js jisho null 8.0 1037.1 503 False 0 []
206 searchmysite sms xpath "general" 1037.1 503 False 0 []
207 annas archive aa annas_archive null 5 1037.2 503 False 0 []
208 pexels pe pexels null 1037.2 503 False 0 []
209 wttr.in wttr wttr null 8.0 1037.2 503 False 0 []
210 imdb imdb imdb null 8.0 1037.3 503 False 0 []
211 crates.io crates crates null 8.0 1037.4 503 False 0 []
212 deviantart da deviantart null 8.0 1037.4 503 False 0 []
213 qwant news qwn qwant "news" 1037.4 503 False 0 []
214 crossref cr crossref null 8.0 1037.5 503 False 0 []
215 stackoverflow st stackexchange ["it", "q&a"] 1037.6 503 False 0 []
216 500px 500 500px null 5 1037.7 503 False 0 []
217 mixcloud mc mixcloud null 1037.7 503 False 0 []
218 presearch news psnews presearch ["news", "web"] 8.0 1037.7 503 False 0 []
219 lucide luc lucide null 8.0 1037.8 503 False 0 []
220 boardreader boa boardreader null 1038.0 503 False 0 []
221 seznam szn seznam null 1038.0 503 False 0 []
222 tokyotoshokan tt tokyotoshokan null 8.0 1038.0 503 False 0 []
223 9gag 9g 9gag null 1038.1 503 False 0 []
224 github code ghc github_code null 8.0 1038.3 503 False 0 []
225 wiby wib json_engine ["general", "web"] 1038.3 503 False 0 []
226 pixiv pv pixiv null 1038.8 503 False 0 []
227 selfhst icons si selfhst null 1038.9 503 False 0 []
228 huggingface spaces hfs huggingface null 1039.1 503 False 0 []
229 unsplash us unsplash null 1039.3 503 False 0 []
230 national vulnerability database nvd nvd null 1039.6 503 False 0 []
231 brave.images brimg brave ["images", "web"] 1040.5 503 False 0 []
232 erowid ew xpath [] 1040.6 503 False 0 []
233 alpine linux packages alp alpinelinux null 1044.1 503 False 0 []
234 senscritique scr senscritique null 8.0 1072.9 503 False 0 []
235 pubmed pub pubmed null 1073.0 503 False 0 []
236 wiktionary wt mediawiki ["dictionaries", "wikimedia"] 1073.0 503 False 0 []
237 caddy.community caddy discourse ["it", "q&a"] 1073.3 503 False 0 []
238 cara ca cara null 1076.7 503 False 0 []
239 anaconda conda xpath "it" 8.0 1077.1 503 False 0 []
240 kickass kc kickass null 8.0 1077.1 503 False 0 []
241 duckduckgo weather ddw duckduckgo_weather null 1077.2 503 False 0 []
242 rubygems rbg xpath ["it", "packages"] 1077.4 503 False 0 []
243 nyaa nt nyaa null 1077.5 503 False 0 []
244 github gh github null 1077.7 503 False 0 []
245 cachy os packages cos cachy_os null 1080.3 503 False 0 []

Some files were not shown because too many files have changed in this diff Show More