Compare commits
64 Commits
838eec30a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e25801509 | |||
| 23bfca51e4 | |||
| 356108e792 | |||
| 4813196b0a | |||
| 2283020e51 | |||
| 17d211bf93 | |||
| 5a26dfde2a | |||
| b55af1eff0 | |||
| 81f711ef0b | |||
| 2c7a02f587 | |||
| b82c6d392d | |||
| 3a1fd1c5d7 | |||
| 59334e4057 | |||
| 6dc9933853 | |||
| 477f090dfa | |||
| 70152c61b1 | |||
| 52ade88840 | |||
| e0054d4cbc | |||
| ae0d53e295 | |||
| f99acf5d50 | |||
| d8b7832b14 | |||
| 2fdc996af9 | |||
| bece7be267 | |||
| 538f3e227a | |||
| 46494d2089 | |||
| 12ae077ac7 | |||
| e5fcde6940 | |||
| b2b1c87822 | |||
| caf1ac1c01 | |||
| 7985fe9641 | |||
| c70cb2a9f0 | |||
| 01b132266a | |||
| 818baa349a | |||
| 9293d48c1b | |||
| 68ed783d6c | |||
| 9904b9bd78 | |||
| 7ed199aaf1 | |||
| 9ff51ac2f3 | |||
| be4000b774 | |||
| ef162de168 | |||
| 1155de4867 | |||
| d452d1747e | |||
| c0d4bf8b2b | |||
| 8d89c2a0f6 | |||
| 2ad57887cd | |||
| 637456ee34 | |||
| e28125079c | |||
| ce0731c2f2 | |||
| e77f69c5c4 | |||
| 5434f3eb47 | |||
| 4a979ba7c3 | |||
| 4fe4becdd5 | |||
| 065b4ac319 | |||
| aa6133e3ed | |||
| d2b64ad5d6 | |||
| 2b79f20e19 | |||
| d9418fac98 | |||
| 0d25f4d1ef | |||
| 71a71530a3 | |||
| 9b37ca42d6 | |||
| 075eded2ba | |||
| eb6e8bbfff | |||
| 1e58c18bbc | |||
| 190bb2b756 |
+13
@@ -0,0 +1,13 @@
|
||||
[run]
|
||||
source = backend
|
||||
omit =
|
||||
backend/tests/*
|
||||
backend/test_*.py
|
||||
backend/__pycache__/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
dist
|
||||
htmlcov
|
||||
.pytest_cache
|
||||
.coverage
|
||||
.build-check
|
||||
reports
|
||||
.git
|
||||
docker-data
|
||||
backend/.env
|
||||
backend/models
|
||||
backend/__pycache__
|
||||
backend/tests/__pycache__
|
||||
**/.DS_Store
|
||||
+20
-10
@@ -1,11 +1,21 @@
|
||||
VITE_API_URL=http://localhost:8000/v1/completions
|
||||
VITE_OCR_URL=http://localhost:8000/v1/ocr
|
||||
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=
|
||||
|
||||
# Ollama 配置
|
||||
OLLAMA_HOST=http://192.168.0.120:11434
|
||||
OLLAMA_MODEL=gpt-oss:120b
|
||||
|
||||
# 可选:其他配置
|
||||
# 如果ollama需要认证,可以使用以下变量
|
||||
# OLLAMA_USERNAME=your_username
|
||||
# OLLAMA_PASSWORD=your_password
|
||||
# Document block compression context limit (characters)
|
||||
VITE_DOC_COMPRESS_CONTEXT_LIMIT=128000
|
||||
|
||||
+38
@@ -12,13 +12,51 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Python
|
||||
backend/models/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc.*
|
||||
.python-version
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
api_performance_report.md
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
**/xcuserdata/
|
||||
*.xcuserstate
|
||||
DerivedData/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
# IDE directories
|
||||
.kilocode/
|
||||
.kilo/
|
||||
.codex/
|
||||
|
||||
# Agent/runtime state and local verification artifacts
|
||||
.omx/
|
||||
.tmp-*.png
|
||||
tmp-*.txt
|
||||
|
||||
# Docker runtime data must live under /Users/allenyuan/lit, never in this repo.
|
||||
docker-data/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# rules.md
|
||||
|
||||
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,inline suggustions实现和数据解析。
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停
|
||||
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位
|
||||
- 每次完成任务前都要反复检查代码,确保代码准确无误
|
||||
@@ -0,0 +1,91 @@
|
||||
- generic [ref=e3]:
|
||||
- generic:
|
||||
- button "设置" [ref=e5] [cursor=pointer]:
|
||||
- img [ref=e6]
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- heading "设置" [level=2] [ref=e11]
|
||||
- button "关闭" [ref=e12] [cursor=pointer]:
|
||||
- img [ref=e13]
|
||||
- generic [ref=e16]:
|
||||
- generic [ref=e17]:
|
||||
- heading "外观" [level=3] [ref=e18]
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: 外观
|
||||
- combobox [ref=e21]:
|
||||
- option "深色"
|
||||
- option "浅色"
|
||||
- option "跟随系统" [selected]
|
||||
- option "暖色调"
|
||||
- option "读书灯"
|
||||
- option "自定义图片"
|
||||
- generic [ref=e22]:
|
||||
- heading "视图" [level=3] [ref=e23]
|
||||
- generic [ref=e24]:
|
||||
- button "编辑器" [ref=e25] [cursor=pointer]:
|
||||
- img [ref=e26]
|
||||
- generic [ref=e28]: 编辑器
|
||||
- button "文档" [ref=e29] [cursor=pointer]:
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]: 文档
|
||||
- generic [ref=e34]:
|
||||
- heading "PRO 模式" [level=3] [ref=e35]
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e37]: PRO 思考程度
|
||||
- generic [ref=e38]:
|
||||
- button "低" [ref=e39] [cursor=pointer]
|
||||
- button "中" [ref=e40] [cursor=pointer]
|
||||
- button "高" [ref=e41] [cursor=pointer]
|
||||
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
|
||||
- generic [ref=e43]:
|
||||
- heading "模型智能" [level=3] [ref=e44]
|
||||
- generic [ref=e45]:
|
||||
- generic [ref=e46]: 思考程度
|
||||
- generic [ref=e47]:
|
||||
- button "低" [ref=e48] [cursor=pointer]
|
||||
- button "中" [ref=e49] [cursor=pointer]
|
||||
- button "高" [ref=e50] [cursor=pointer]
|
||||
- paragraph [ref=e51]: 直接补全(最快)
|
||||
- generic [ref=e52]:
|
||||
- generic [ref=e53]: "防抖时间: 1000ms"
|
||||
- slider [ref=e54]: "1000"
|
||||
- generic [ref=e55]:
|
||||
- heading "隐私与偏好" [level=3] [ref=e56]
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: 隐私模式
|
||||
- button [ref=e59] [cursor=pointer]
|
||||
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
|
||||
- generic [ref=e62]:
|
||||
- generic [ref=e63]: 语言
|
||||
- combobox [disabled] [ref=e64]:
|
||||
- option "自动检测" [selected]
|
||||
- option "Chinese"
|
||||
- option "English"
|
||||
- option "Japanese"
|
||||
- option "Korean"
|
||||
- option "German"
|
||||
- option "French"
|
||||
- generic [ref=e65]:
|
||||
- generic [ref=e66]: 货币
|
||||
- combobox [disabled] [ref=e67]:
|
||||
- option "自动检测" [selected]
|
||||
- option "CNY (¥)"
|
||||
- option "USD ($)"
|
||||
- option "EUR (€)"
|
||||
- option "JPY (¥)"
|
||||
- option "KRW (₩)"
|
||||
- option "GBP (£)"
|
||||
- option "AUD ($)"
|
||||
- option "CAD ($)"
|
||||
- generic [ref=e68]:
|
||||
- heading "语音设置" [level=3] [ref=e69]
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 声音描述
|
||||
- textbox "例如:用温柔的语气说" [ref=e72]
|
||||
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
|
||||
- generic [ref=e74]:
|
||||
- heading "关于我们" [level=3] [ref=e75]
|
||||
- generic [ref=e76]:
|
||||
- heading "llm-in-text" [level=4] [ref=e77]
|
||||
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
|
||||
- paragraph [ref=e79]: v0.0.0
|
||||
@@ -0,0 +1,124 @@
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e80]:
|
||||
- textbox [active] [ref=e83]:
|
||||
- heading "欢迎使用 LLM-IN-TEXT" [level=1] [ref=e84]
|
||||
- paragraph [ref=e85]: 即时可用的 LLM 系统
|
||||
- paragraph [ref=e86]: 在下方开始创作吧...
|
||||
- generic [ref=e87]:
|
||||
- button "Undo" [disabled] [ref=e88]:
|
||||
- img [ref=e89]
|
||||
- button "Redo" [disabled] [ref=e92]:
|
||||
- img [ref=e93]
|
||||
- generic [ref=e96]:
|
||||
- button "上传" [ref=e97] [cursor=pointer]:
|
||||
- img [ref=e98]
|
||||
- generic: 上传
|
||||
- button "导入 Markdown" [ref=e104] [cursor=pointer]:
|
||||
- img [ref=e105]
|
||||
- generic: 导入 Markdown
|
||||
- button "导出 Markdown" [ref=e109] [cursor=pointer]:
|
||||
- img [ref=e110]
|
||||
- generic: 导出 Markdown
|
||||
- button "禁用 AI" [ref=e114] [cursor=pointer]:
|
||||
- img [ref=e115]
|
||||
- generic: 禁用 AI
|
||||
- generic [ref=e119]:
|
||||
- img [ref=e120]
|
||||
- text: 0 KB
|
||||
- generic [ref=e122]:
|
||||
- button "模板" [ref=e124] [cursor=pointer]:
|
||||
- img [ref=e125]
|
||||
- generic: 模板
|
||||
- button "清除" [ref=e128] [cursor=pointer]:
|
||||
- img [ref=e129]
|
||||
- generic: 清除文档
|
||||
- generic:
|
||||
- button "设置" [ref=e5] [cursor=pointer]:
|
||||
- img [ref=e6]
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- heading "设置" [level=2] [ref=e11]
|
||||
- button "关闭" [ref=e12] [cursor=pointer]:
|
||||
- img [ref=e13]
|
||||
- generic [ref=e16]:
|
||||
- generic [ref=e17]:
|
||||
- heading "外观" [level=3] [ref=e18]
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: 外观
|
||||
- combobox [ref=e21]:
|
||||
- option "深色"
|
||||
- option "浅色"
|
||||
- option "跟随系统" [selected]
|
||||
- option "暖色调"
|
||||
- option "读书灯"
|
||||
- option "自定义图片"
|
||||
- generic [ref=e22]:
|
||||
- heading "视图" [level=3] [ref=e23]
|
||||
- generic [ref=e24]:
|
||||
- button "编辑器" [ref=e25] [cursor=pointer]:
|
||||
- img [ref=e26]
|
||||
- generic [ref=e28]: 编辑器
|
||||
- button "文档" [ref=e29] [cursor=pointer]:
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]: 文档
|
||||
- generic [ref=e34]:
|
||||
- heading "PRO 模式" [level=3] [ref=e35]
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e37]: PRO 思考程度
|
||||
- generic [ref=e38]:
|
||||
- button "低" [ref=e39] [cursor=pointer]
|
||||
- button "中" [ref=e40] [cursor=pointer]
|
||||
- button "高" [ref=e41] [cursor=pointer]
|
||||
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
|
||||
- generic [ref=e43]:
|
||||
- heading "模型智能" [level=3] [ref=e44]
|
||||
- generic [ref=e45]:
|
||||
- generic [ref=e46]: 思考程度
|
||||
- generic [ref=e47]:
|
||||
- button "低" [ref=e48] [cursor=pointer]
|
||||
- button "中" [ref=e49] [cursor=pointer]
|
||||
- button "高" [ref=e50] [cursor=pointer]
|
||||
- paragraph [ref=e51]: 直接补全(最快)
|
||||
- generic [ref=e52]:
|
||||
- generic [ref=e53]: "防抖时间: 1000ms"
|
||||
- slider [ref=e54]: "1000"
|
||||
- generic [ref=e55]:
|
||||
- heading "隐私与偏好" [level=3] [ref=e56]
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]: 隐私模式
|
||||
- button [ref=e59] [cursor=pointer]
|
||||
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
|
||||
- generic [ref=e62]:
|
||||
- generic [ref=e63]: 语言
|
||||
- combobox [disabled] [ref=e64]:
|
||||
- option "自动检测" [selected]
|
||||
- option "Chinese"
|
||||
- option "English"
|
||||
- option "Japanese"
|
||||
- option "Korean"
|
||||
- option "German"
|
||||
- option "French"
|
||||
- generic [ref=e65]:
|
||||
- generic [ref=e66]: 货币
|
||||
- combobox [disabled] [ref=e67]:
|
||||
- option "自动检测" [selected]
|
||||
- option "CNY (¥)"
|
||||
- option "USD ($)"
|
||||
- option "EUR (€)"
|
||||
- option "JPY (¥)"
|
||||
- option "KRW (₩)"
|
||||
- option "GBP (£)"
|
||||
- option "AUD ($)"
|
||||
- option "CAD ($)"
|
||||
- generic [ref=e68]:
|
||||
- heading "语音设置" [level=3] [ref=e69]
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]: 声音描述
|
||||
- textbox "例如:用温柔的语气说" [ref=e72]
|
||||
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
|
||||
- generic [ref=e74]:
|
||||
- heading "关于我们" [level=3] [ref=e75]
|
||||
- generic [ref=e76]:
|
||||
- heading "llm-in-text" [level=4] [ref=e77]
|
||||
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
|
||||
- paragraph [ref=e79]: v0.0.0
|
||||
+129
@@ -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 组装逻辑(后端参考)
|
||||
@@ -0,0 +1,196 @@
|
||||
# LLM in Text 仓库指引 (v0.2.0)
|
||||
|
||||
本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。
|
||||
|
||||
## 项目定位
|
||||
|
||||
- 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。
|
||||
- 前端技术栈:Vue 3 + Vite + Milkdown/Crepe + Pinia + Vue Router。
|
||||
- 后端技术栈:FastAPI + Python + OpenAI-compatible LLM endpoint + Redis Streams。
|
||||
- 仓库内另有 `InTEX/` iOS 客户端工程,技术栈为 SwiftUI + SwiftData;当前仍是独立的 Xcode 初始工程,尚未接入 Web/Docker 后端。
|
||||
- 项目版本: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、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。
|
||||
|
||||
## 先看哪里
|
||||
|
||||
- 项目概览和运行说明:README.md
|
||||
- 前端入口:src/main.js
|
||||
- 路由:src/router/index.js
|
||||
- 编辑器主组件:src/components/MilkdownEditor.vue
|
||||
- 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(含 PRO 模式模板)
|
||||
- TTS 路由:backend/tts_asr.py
|
||||
- **任务队列系统**:backend/job_system.py(Redis Streams 异步任务管理)
|
||||
- **Worker 进程入口**:backend/worker.py(消费 Redis Streams 的独立 worker)
|
||||
- **任务处理器注册表**:backend/job_handlers.py(completion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器)
|
||||
- **会话管理**:backend/session_store.py(内存 + PostgreSQL 双后端)
|
||||
- **API/LLM 审计日志**:backend/audit_store.py(PostgreSQL 持久化)
|
||||
- **风控配置**:backend/risk_config.py(环境变量驱动的配置数据类)
|
||||
- **风控引擎**:backend/risk_control.py(速率限制、并发控制、熔断器、预算追踪)
|
||||
- **验证码路由**:backend/captcha_api.py(FastAPI router)
|
||||
- **文档存储**:backend/docs_store.py(MIME 类型检测、文本/二进制分类)
|
||||
- **LLM 策略解析**:backend/llm_policy.py(按 job_type 解析模型配置)
|
||||
- **网页搜索块组件**:src/components/WebSearchBlockCrepe.vue(可折叠/压缩/删除的搜索结果卡片)
|
||||
- **网页搜索块插件**:src/plugins/webSearchBlockPlugin.ts(```llm-websearch fenced code 语法)
|
||||
- **OCR 图片包装器**:src/components/OCRImageWrapper.vue(加载/成功/失败状态覆盖层)
|
||||
- **验证码组件**:src/components/CaptchaComponent.vue(vue3-captcha 封装)
|
||||
- **SSE 流式解析**:src/utils/sse.ts(Server-Sent Events 事件解析)
|
||||
- **文档管理 API**:src/utils/docsApi.js(上传/预览/删除接口客户端)
|
||||
- **PRO 功能接受追踪**:src/utils/proAccept.js(使用分析和统计)
|
||||
- **Cookie 策略管理**:src/utils/cookie_policy.js(SameSite/Secure 标志处理)
|
||||
- **字符串工具**:src/utils/string.ts(长度计算、编码检测)
|
||||
- **网页搜索上下文提取**:src/utils/webSearch.js(搜索结果 Markdown 构建与解析)
|
||||
- **测试配置和入口**:pytest.ini、backend/tests/run_tests.py
|
||||
- **iOS 客户端工程**:InTEX/InTEX.xcodeproj(scheme: `InTEX`)
|
||||
- **iOS 客户端入口**:InTEX/InTEX/InTEXApp.swift、InTEX/InTEX/ContentView.swift
|
||||
|
||||
## 稳定事实
|
||||
|
||||
- **功能块禁止嵌套**:`doc_block`、`pro_block`、`upload_block` 的 schema 均设 `atom: true, isolating: true`,ProseMirror 层面强制禁止互相嵌套。DocBlockCrepe.vue 的嵌套 Crepe 编辑器仅注册 copilotPlugin + hiddenText*,不注册任何功能块插件。修改时不得移除 atom/isolating 属性或在嵌套编辑器中引入功能块插件。
|
||||
- **功能块导入解析**:各功能块的 Remark 插件(`docBlockRemark`, `proBlockRemark`, `uploadBlockRemark`)负责从 Markdown AST 识别对应语法并转换为节点。doc_block Remark 同时支持 fenced code (`llm-file`) 和 legacy HTML tag (`<doc_type=...>`)。解析链路必须保持完整,否则导入后无法自动复原。
|
||||
- **功能块导出序列化**:doc_block 的 `toMarkdown` runner 输出 legacy HTML tag,但 `getExportMarkdown()` 中通过 `transformLegacyDocBlocksForExport()` 转换为 fenced code block。pro_block/upload_block 的 toMarkdown/leafText 直接输出标准语法。修改时需验证导出后再次导入能完整复原。
|
||||
- **Web Search 块语法**:web_search_block 使用 fenced code ```llm-websearch date=... 语法,与 doc_block(llm-file)、pro_block([PRO])、upload_block({{{}}}) 并列。触发语法为 [WEBSEARCH](不区分大小写),由 `webSearchBlockPlugin.ts` 处理。
|
||||
- **Store 同步格式**:`scheduleMarkdownSync()` emit markdown 不做转换(doc_block 为 legacy HTML tag),store 中始终是 legacy format。`syncInitialMarkdown()` / `getExportMarkdown()` 负责格式转换。
|
||||
- **上传块生命周期**:upload_block 是临时占位符,文件上传后被替换为 image node(图片)或 doc_block(文档),不会与 pro_block 共存。
|
||||
- **PRO block escape/unescape**:`escapeProBlockContent()` / `unescapeProBlockSyntax()` 处理 `\`, `]`, `}`, newline,确保指令 round-trip 正确。
|
||||
- **补全接口当前不是 SSE**;前端用普通 POST 请求拿 JSON 响应。
|
||||
- **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。`job_system.py` 定义 JOB_TYPES(completion/pro_completion/web_search/compress/ocr/convert/tts/asr),`worker.py` 消费队列,`job_handlers.py` 注册各类型处理器。所有任务支持并发控制、速率限制和熔断器模式。
|
||||
- **会话追踪**:`session_store.py` 提供 InMemorySessionStore(开发)和 PostgresSessionStore(生产),通过 session_hash + ip_hash 追踪请求身份。
|
||||
- **风控系统**:`risk_config.py` + `risk_control.py` 实现速率限制(滑动窗口)、并发控制、熔断器模式和预算追踪,所有阈值通过环境变量配置。
|
||||
- 前端会生成 X-Request-Id,并在请求被中止时额外调用 /v1/completions/cancel。
|
||||
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
|
||||
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
|
||||
- /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。
|
||||
- **AI 开关是全局广播状态**:MilkdownEditor.vue 通过 `llm-in-text:copilot-toggle` 同步主编辑器、文档块嵌套编辑器、网页搜索块嵌套编辑器;修 ghost text 时要同时检查这三处。
|
||||
- **设置项统一使用 country**:前后端请求、prompt、store、设置面板只使用 `country`。
|
||||
- **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`。
|
||||
- **LLM/PRO/OCR 模型统一**:`LLM_MODEL`、`PRO_LLM_MODEL`、`VLM_MODEL` 的当前默认值和 Docker 运行值统一为 `Nex-N2-mini-mlx-OptiQ-8bit-MTP`。网页搜索模型仍可通过 `RISK_WEB_SEARCH_MODEL` 独立配置。
|
||||
- **OCR 完成状态不等于识别正确**:Redis 任务、审计入库和 SSE `done` 只能证明链路完成。2026-06-27 的清晰文本图片真实测试中,Nex OCR 仍可能返回未识别文本;用户已明确暂停继续修复 OCR 精度,不要把链路完成误报为 OCR 正常。
|
||||
- **审计 SQL 参数必须严格对齐**:`llm_call_audit` 当前包含 `queue_ms`、`run_ms`、`total_ms`,修改 INSERT 时必须保持列数、`%s` 占位符数和参数数一致;`backend/tests/test_audit_store.py` 覆盖此回归。
|
||||
- **TTS/ASR 当前真实实现**:backend/tts_asr.py 统一通过 `LLM_BASE_URL` + `LLM_API_KEY` 调用 OpenAI-compatible Speech API,默认模型为 `Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit` 与 `Qwen3-ASR-0.6B-8bit`。VoiceDesign 请求字段必须使用复数 `instructions`;空声音描述必须回退到 `TTS_DEFAULT_INSTRUCTIONS`,不能向上游发送空指令。
|
||||
- **六按钮收缩菜单**:MilkdownEditor.vue 的 upload/import/export/AI/template/clear 六个动作统一收进右下角 `.more-actions`。视口宽度 `>= 520px` 横向展开,窄屏纵向展开;再次点击主按钮、再次点击已打开的 export/template、点击 backdrop 或按 Escape 都必须完整收缩回右下角省略号。
|
||||
- **iOS 工程边界**:`InTEX/` 当前是 SwiftUI + SwiftData 示例骨架,包含 `InTEX`、`InTEXTests`、`InTEXUITests` 三个 target。不要声称它已接入编辑器或 Docker API;提交时禁止加入 `xcuserdata`、`*.xcuserstate` 和 DerivedData。
|
||||
|
||||
## 常用命令
|
||||
|
||||
- 前端安装:npm install
|
||||
- 前端开发:npm run dev
|
||||
- 前端构建:npm run build
|
||||
- 后端安装:pip install -r backend/requirements.txt
|
||||
- 后端启动:python backend/main.py
|
||||
- 可选启动方式:uvicorn backend.main:app --reload --port 8001
|
||||
- iOS 工程检查:xcodebuild -project InTEX/InTEX.xcodeproj -list
|
||||
- iOS 模拟器构建:优先使用 XcodeBuildMCP;命令行回退可用 `xcodebuild -project InTEX/InTEX.xcodeproj -scheme InTEX -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build`
|
||||
- 全量测试:pytest
|
||||
- 常用窄测试:
|
||||
- pytest backend/tests/test_main_endpoints.py -v
|
||||
- pytest backend/tests/test_main_cancel.py -v
|
||||
- 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 定义的服务**:api(FastAPI)、worker(任务消费者)、frontend(Nginx)、postgres(审计+会话存储)、redis(任务队列)、searxng(网页搜索后端)、firecrawl(网页内容抓取)。每次 `docker compose up --build` 后,必须验证所有目标服务(api/worker/frontend/postgres/redis)是否处于 Up 状态。
|
||||
- **前端网络硬约定**:前端 API 必须调用 `https://api.imageteach.tech:8002/` 反向代理,不要让前端调用 Docker 内 `api` 服务、本机 `localhost:8001`、`localhost:8081` 或同源 `/v1` 代理。网络和反向代理由外部配置处理,除非用户明确要求,不要新增或恢复前端到 Docker 后端的代理。
|
||||
- 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。
|
||||
- 首选更新方式:
|
||||
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 和任务共享临时目录。
|
||||
- Docker BuildKit 导出缓存统一落在 `docker-data/build-cache/{frontend,api,worker}`。`docker-compose.yml` 的 `cache_from/cache_to` 与 Dockerfile 的 apt/pip/npm cache mount 必须保留;首次构建填充缓存,后续相同源码构建应全部命中缓存。
|
||||
- `.dockerignore` 必须排除 `backend/models`、`backend/.env`、`docker-data`、`node_modules`、`.git` 等大目录或敏感文件。`backend/models` 是约 4.9GB 的旧本地模型目录,当前 API-based TTS/ASR 不应把它发送进构建上下文。
|
||||
- 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。
|
||||
- 当前 Docker 部署的 `backend/requirements.docker.txt` 已包含 OCR、转换、队列和基础 API 依赖;`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`
|
||||
|
||||
## 代码约定
|
||||
|
||||
- 不要把整个仓库当成“全小写+短横线命名”项目。当前实际情况是:
|
||||
- Vue 组件和视图多为 PascalCase
|
||||
- 前端工具模块多为小写 .js
|
||||
- 插件层使用 TypeScript
|
||||
- Python 使用 snake_case
|
||||
- 以就地风格为准,不要顺手做全仓格式统一。
|
||||
- UI 文案和代理回复默认使用中文。
|
||||
- 不要修改 milkdown-docs/,它是只读参考资料。
|
||||
- 不要新增硬编码密钥、空 catch/except、as any、@ts-ignore 之类的扩散式技术债。
|
||||
- 代理在这个仓库里应优先做局部、可验证的修改,不要做无关重构。
|
||||
|
||||
## 调试路径
|
||||
|
||||
- 补全问题:
|
||||
src/components/MilkdownEditor.vue
|
||||
-> src/plugins/copilotPlugin.ts
|
||||
-> src/utils/api.js
|
||||
-> backend/main.py
|
||||
-> backend/prompt.py / backend/llm.py
|
||||
|
||||
- OCR 问题:
|
||||
src/components/MilkdownEditor.vue
|
||||
-> backend/main.py
|
||||
-> backend/llm.py
|
||||
|
||||
- 文档转换问题:
|
||||
src/utils/convert.js
|
||||
-> backend/main.py
|
||||
|
||||
- TTS 问题:
|
||||
src/components/TTSMenu.vue / src/components/TTSPlayer.vue / src/components/MilkdownEditor.vue
|
||||
-> src/utils/api.js
|
||||
-> backend/tts_asr.py
|
||||
|
||||
## 测试和产物
|
||||
|
||||
- pytest.ini 对 backend.main、backend.llm、backend.prompt、backend.geoip、backend.prompts、backend.tts_asr 设了覆盖率门槛,低于 90% 会失败。
|
||||
- 默认测试目录是 backend/tests。
|
||||
- 常见生成产物包括 dist、htmlcov、.pytest_cache、api_performance_report.md;它们不是源代码。
|
||||
|
||||
## 文档注意事项
|
||||
|
||||
- README.md 对产品功能有参考价值,但其中补全、TTS/ASR 和部分接口说明已经比代码旧。
|
||||
- backend/TTS_ASR_MACOS_FIX.md 和 backend/tests/TESTING_GUIDE.md 更适合作为历史背景,不应在与代码冲突时被当成事实来源。
|
||||
- 修改行为时,优先参考实现代码和对应测试,再决定是否同步普通文档。
|
||||
@@ -0,0 +1,21 @@
|
||||
ARG DOCKER_REGISTRY_PREFIX=
|
||||
FROM ${DOCKER_REGISTRY_PREFIX}node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --prefer-offline --no-audit
|
||||
|
||||
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
|
||||
@@ -0,0 +1,592 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A75D91532FF0146B00EE6196 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A75D91392FF0146A00EE6196 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A75D91402FF0146A00EE6196;
|
||||
remoteInfo = InTEX;
|
||||
};
|
||||
A75D915D2FF0146B00EE6196 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A75D91392FF0146A00EE6196 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A75D91402FF0146A00EE6196;
|
||||
remoteInfo = InTEX;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A75D91412FF0146A00EE6196 /* InTEX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InTEX.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A75D91522FF0146B00EE6196 /* InTEXTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InTEXTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A75D915C2FF0146B00EE6196 /* InTEXUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InTEXUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A75D91642FF0146B00EE6196 /* Exceptions for "InTEX" folder in "InTEX" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = A75D91402FF0146A00EE6196 /* InTEX */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A75D91432FF0146A00EE6196 /* InTEX */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A75D91642FF0146B00EE6196 /* Exceptions for "InTEX" folder in "InTEX" target */,
|
||||
);
|
||||
path = InTEX;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A75D91552FF0146B00EE6196 /* InTEXTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = InTEXTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A75D915F2FF0146B00EE6196 /* InTEXUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = InTEXUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A75D913E2FF0146A00EE6196 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D914F2FF0146B00EE6196 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D91592FF0146B00EE6196 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A75D91382FF0146A00EE6196 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A75D91432FF0146A00EE6196 /* InTEX */,
|
||||
A75D91552FF0146B00EE6196 /* InTEXTests */,
|
||||
A75D915F2FF0146B00EE6196 /* InTEXUITests */,
|
||||
A75D91422FF0146A00EE6196 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A75D91422FF0146A00EE6196 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A75D91412FF0146A00EE6196 /* InTEX.app */,
|
||||
A75D91522FF0146B00EE6196 /* InTEXTests.xctest */,
|
||||
A75D915C2FF0146B00EE6196 /* InTEXUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A75D91402FF0146A00EE6196 /* InTEX */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A75D91652FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEX" */;
|
||||
buildPhases = (
|
||||
A75D913D2FF0146A00EE6196 /* Sources */,
|
||||
A75D913E2FF0146A00EE6196 /* Frameworks */,
|
||||
A75D913F2FF0146A00EE6196 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A75D91432FF0146A00EE6196 /* InTEX */,
|
||||
);
|
||||
name = InTEX;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = InTEX;
|
||||
productReference = A75D91412FF0146A00EE6196 /* InTEX.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A75D91512FF0146B00EE6196 /* InTEXTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A75D916A2FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEXTests" */;
|
||||
buildPhases = (
|
||||
A75D914E2FF0146B00EE6196 /* Sources */,
|
||||
A75D914F2FF0146B00EE6196 /* Frameworks */,
|
||||
A75D91502FF0146B00EE6196 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A75D91542FF0146B00EE6196 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A75D91552FF0146B00EE6196 /* InTEXTests */,
|
||||
);
|
||||
name = InTEXTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = InTEXTests;
|
||||
productReference = A75D91522FF0146B00EE6196 /* InTEXTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
A75D915B2FF0146B00EE6196 /* InTEXUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A75D916D2FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEXUITests" */;
|
||||
buildPhases = (
|
||||
A75D91582FF0146B00EE6196 /* Sources */,
|
||||
A75D91592FF0146B00EE6196 /* Frameworks */,
|
||||
A75D915A2FF0146B00EE6196 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A75D915E2FF0146B00EE6196 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A75D915F2FF0146B00EE6196 /* InTEXUITests */,
|
||||
);
|
||||
name = InTEXUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = InTEXUITests;
|
||||
productReference = A75D915C2FF0146B00EE6196 /* InTEXUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A75D91392FF0146A00EE6196 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2650;
|
||||
LastUpgradeCheck = 2650;
|
||||
TargetAttributes = {
|
||||
A75D91402FF0146A00EE6196 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
};
|
||||
A75D91512FF0146B00EE6196 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = A75D91402FF0146A00EE6196;
|
||||
};
|
||||
A75D915B2FF0146B00EE6196 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = A75D91402FF0146A00EE6196;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A75D913C2FF0146A00EE6196 /* Build configuration list for PBXProject "InTEX" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A75D91382FF0146A00EE6196;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A75D91422FF0146A00EE6196 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A75D91402FF0146A00EE6196 /* InTEX */,
|
||||
A75D91512FF0146B00EE6196 /* InTEXTests */,
|
||||
A75D915B2FF0146B00EE6196 /* InTEXUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A75D913F2FF0146A00EE6196 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D91502FF0146B00EE6196 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D915A2FF0146B00EE6196 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A75D913D2FF0146A00EE6196 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D914E2FF0146B00EE6196 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A75D91582FF0146B00EE6196 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A75D91542FF0146B00EE6196 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A75D91402FF0146A00EE6196 /* InTEX */;
|
||||
targetProxy = A75D91532FF0146B00EE6196 /* PBXContainerItemProxy */;
|
||||
};
|
||||
A75D915E2FF0146B00EE6196 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A75D91402FF0146A00EE6196 /* InTEX */;
|
||||
targetProxy = A75D915D2FF0146B00EE6196 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A75D91662FF0146B00EE6196 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = InTEX/InTEX.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = InTEX/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEX;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A75D91672FF0146B00EE6196 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = InTEX/InTEX.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = InTEX/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEX;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A75D91682FF0146B00EE6196 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A75D91692FF0146B00EE6196 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A75D916B2FF0146B00EE6196 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEXTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/InTEX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/InTEX";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A75D916C2FF0146B00EE6196 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEXTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/InTEX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/InTEX";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A75D916E2FF0146B00EE6196 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEXUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = InTEX;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A75D916F2FF0146B00EE6196 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = imageteach.InTEXUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = InTEX;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A75D913C2FF0146A00EE6196 /* Build configuration list for PBXProject "InTEX" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A75D91682FF0146B00EE6196 /* Debug */,
|
||||
A75D91692FF0146B00EE6196 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A75D91652FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEX" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A75D91662FF0146B00EE6196 /* Debug */,
|
||||
A75D91672FF0146B00EE6196 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A75D916A2FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEXTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A75D916B2FF0146B00EE6196 /* Debug */,
|
||||
A75D916C2FF0146B00EE6196 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A75D916D2FF0146B00EE6196 /* Build configuration list for PBXNativeTarget "InTEXUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A75D916E2FF0146B00EE6196 /* Debug */,
|
||||
A75D916F2FF0146B00EE6196 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A75D91392FF0146A00EE6196 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// InTEX
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// InTEXApp.swift
|
||||
// InTEX
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct InTEXApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Item.swift
|
||||
// InTEX
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// InTEXTests.swift
|
||||
// InTEXTests
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import InTEX
|
||||
|
||||
struct InTEXTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
// Swift Testing Documentation
|
||||
// https://developer.apple.com/documentation/testing
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// InTEXUITests.swift
|
||||
// InTEXUITests
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class InTEXUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
// XCUIAutomation Documentation
|
||||
// https://developer.apple.com/documentation/xcuiautomation
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// InTEXUITestsLaunchTests.swift
|
||||
// InTEXUITests
|
||||
//
|
||||
// Created by allenyuan on 2026/6/27.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class InTEXUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
// XCUIAutomation Documentation
|
||||
// https://developer.apple.com/documentation/xcuiautomation
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -1,262 +1,185 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Mermaid 图表渲染
|
||||
- 导入/导出 Markdown 文件
|
||||
- 导出 DOCX 和 PDF 格式
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
- 多种交互方式:Tab接受、Esc拒绝、点击接受
|
||||
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
### 功能块系统
|
||||
|
||||
编辑器提供三种**功能块**,统一为顶层原子节点(`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
|
||||
|
||||
### 设置面板
|
||||
- 外观主题:亮色/暗色/跟随系统
|
||||
- 背景模式:默认/暖色/阅读灯/自定义图片
|
||||
- 模型智能:低/中/高思考级别
|
||||
- 隐私控制:隐私模式防止发送IP
|
||||
- 多语言界面:中英日韩德法
|
||||
|
||||
### 语音功能
|
||||
- TTS文字转语音(macOS优化,支持Apple Silicon M1/M2/M3)
|
||||
- STT语音转文字(支持多种模型大小和量化)
|
||||
- 自动设备检测(MPS/CUDA/CPU智能切换)
|
||||
- 离线模式支持(模型缓存检查)
|
||||
|
||||
## 技术架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["前端 (Vue3 + Vite)"]
|
||||
A[App.vue] --> B[MilkdownEditor.vue]
|
||||
B --> C[Crepe Editor]
|
||||
C --> D[ProseMirror]
|
||||
D --> E[copilotPlugin.ts]
|
||||
E --> F[copilotGhostMark]
|
||||
E --> G[api.js]
|
||||
end
|
||||
|
||||
subgraph Backend["后端 (FastAPI + Python)"]
|
||||
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
|
||||
H --> J[llm.py<br/>Ollama 调用]
|
||||
J --> K[Ollama API]
|
||||
end
|
||||
|
||||
G -->|POST /v1/completions<br/>SSE 流式响应| H
|
||||
K -->|LLM 响应| J
|
||||
```
|
||||
前端: Vue3 + Vite + Milkdown/Crepe + ProseMirror
|
||||
后端: FastAPI + Python(OpenAI 兼容端点)
|
||||
|
||||
## 项目结构
|
||||
### 功能块架构
|
||||
三种功能块统一为顶层原子节点(`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:...}}}`
|
||||
|
||||
```
|
||||
llm-in-text/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||
│ ├── plugins/
|
||||
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── index.ts # 插件导出
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # API 调用封装
|
||||
│ │ └── config.js # 配置文件
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 服务器
|
||||
│ ├── llm.py # LLM API 调用
|
||||
│ ├── prompt.py # Prompt 构建
|
||||
│ └── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
每个功能块配备独立的 Remark 解析器和序列化器,确保 Markdown 导入导出时自动识别和还原。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
环境: Node.js 18+、Python 3.8+
|
||||
|
||||
### 安装
|
||||
安装:
|
||||
- 前端: npm install
|
||||
- 后端: pip install -r backend/requirements.txt
|
||||
|
||||
启动:
|
||||
- 后端: python backend/main.py (端口8001)
|
||||
- 前端: npm run dev (端口5173)
|
||||
|
||||
## Docker 部署
|
||||
|
||||
将整个项目目录放进本机 `~/lit/llm-in-text` 后,在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
cp backend/.env.example backend/.env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 配置
|
||||
默认对外端口:
|
||||
- 前端: `http://localhost:8080`
|
||||
- 后端: `http://localhost:8001`
|
||||
|
||||
在 `backend/.env` 中配置:
|
||||
持久化目录全部位于当前项目下的 `docker-data/`:
|
||||
- PostgreSQL: `docker-data/postgres`
|
||||
- Redis: `docker-data/redis`
|
||||
- 任务共享临时目录: `docker-data/jobs`
|
||||
|
||||
```env
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
```
|
||||
部署前至少需要修改这些环境变量:
|
||||
- `backend/.env` 中的 `LLM_BASE_URL`
|
||||
- `backend/.env` 中的 `LLM_API_KEY`
|
||||
- `backend/.env` 或 shell 环境中的 `DATABASE_URL`
|
||||
- `backend/.env` 中的 `API_KEY`
|
||||
|
||||
### 启动
|
||||
## API接口
|
||||
|
||||
```bash
|
||||
# 后端(端口 8000)
|
||||
cd backend
|
||||
python main.py
|
||||
- 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/tts 文字转语音
|
||||
- POST /v1/tts-asr/asr 语音转文字
|
||||
|
||||
# 前端(端口 5173)
|
||||
npm run dev
|
||||
```
|
||||
## TTS/ASR环境变量配置
|
||||
|
||||
访问 http://localhost:5173
|
||||
支持以下环境变量来配置TTS/ASR模块:
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /v1/completions
|
||||
|
||||
流式获取补全建议
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"prefix": "# Title\n\nContent ",
|
||||
"suffix": "",
|
||||
"languageId": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
**响应(SSE):**
|
||||
```
|
||||
data: {"content": "here"}
|
||||
data: {"content": "here is"}
|
||||
data: {"done": true}
|
||||
```
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `LLM_BASE_URL` | OpenAI-compatible 上游地址 | 必填 |
|
||||
| `LLM_API_KEY` | OpenAI-compatible 上游密钥 | 必填 |
|
||||
| `TTS_MODEL_ID` | TTS 模型名 | `Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit` |
|
||||
| `ASR_MODEL_ID` | ASR 模型名 | `Qwen3-ASR-0.6B-8bit` |
|
||||
| `TTS_ASR_TTS_TIMEOUT_SECONDS` | TTS 上游超时(秒) | 180 |
|
||||
| `TTS_ASR_ASR_TIMEOUT_SECONDS` | ASR 上游超时(秒) | 300 |
|
||||
| `TTS_ASR_MAX_CONNECTIONS` | Speech API 连接池上限 | 24 |
|
||||
| `TTS_ASR_MAX_KEEPALIVE_CONNECTIONS` | Speech API keepalive 连接数 | 12 |
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端设计
|
||||
### 后端
|
||||
- main.py: FastAPI服务器、SSE流式响应
|
||||
- llm.py: 异步LLM调用(OpenAI兼容)、超时控制
|
||||
- prompt.py: 7条Prompt规则
|
||||
- tts_asr.py: 基于共享 OpenAI-compatible Speech API 的 TTS/ASR 适配层
|
||||
- 统一使用 `LLM_BASE_URL` 和 `LLM_API_KEY`
|
||||
- 通过 `/audio/speech` 与 `/audio/transcriptions` 调用上游
|
||||
- 内建连接池、超时、音频时长估算和上游请求 ID 透传
|
||||
|
||||
#### main.py - FastAPI 服务器
|
||||
- 定义 `/v1/completions` 端点
|
||||
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||
- CORS 配置允许跨域请求
|
||||
|
||||
#### llm.py - LLM 调用封装
|
||||
- 使用 `ollama.AsyncClient` 异步调用
|
||||
- 支持 `think='high'` 思考模式
|
||||
- 返回 `content` 和 `thinking` 字段
|
||||
|
||||
#### prompt.py - Prompt 工程
|
||||
精心设计的 Prompt 模板,包含 7 条核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
|
||||
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
|
||||
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
|
||||
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
|
||||
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
|
||||
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
|
||||
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
|
||||
|
||||
### 前端设计
|
||||
|
||||
#### ProseMirror Mark 系统
|
||||
|
||||
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||
|
||||
```typescript
|
||||
// 定义 ghost mark
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
toDOM: () => ['span', {
|
||||
'data-copilot-ghost': '',
|
||||
class: 'copilot-ghost-text'
|
||||
}, 0]
|
||||
}))
|
||||
|
||||
// CSS 样式
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
#### copilotPlugin 核心逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[用户输入] --> B{文档变化?}
|
||||
B -->|是| C[清除旧建议]
|
||||
C --> D[防抖 500ms]
|
||||
D --> E[发送 API 请求]
|
||||
E --> F[收到建议]
|
||||
F --> G[插入 Ghost Text]
|
||||
|
||||
G --> H{用户操作}
|
||||
H -->|Tab| I[接受建议<br/>移除 mark]
|
||||
H -->|Esc| J[拒绝建议<br/>删除文本]
|
||||
H -->|点击 Ghost| I
|
||||
H -->|继续输入| J
|
||||
```
|
||||
|
||||
#### 关键函数
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `scheduleFetch` | 防抖调度 API 请求 |
|
||||
| `insertGhostText` | 插入带 mark 的建议文本 |
|
||||
| `acceptSuggestion` | Tab 接受建议 |
|
||||
| `rejectSuggestion` | Esc 拒绝建议 |
|
||||
| `clearGhostText` | 清除当前建议 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as Editor (ProseMirror)
|
||||
participant P as copilotPlugin
|
||||
participant A as api.js
|
||||
participant B as Backend
|
||||
participant L as LLM
|
||||
|
||||
U->>E: 输入文本
|
||||
E->>P: view.update()
|
||||
P->>P: 清除旧建议
|
||||
P->>P: 防抖 500ms
|
||||
P->>A: fetchSuggestion(prefix, suffix)
|
||||
A->>B: POST /v1/completions
|
||||
B->>B: build_prompt()
|
||||
B->>L: ollama.chat()
|
||||
L-->>B: {content, thinking}
|
||||
B-->>A: SSE stream
|
||||
A-->>P: suggestion text
|
||||
P->>E: insertGhostText()
|
||||
E-->>U: 显示灰色建议
|
||||
|
||||
alt Tab 键
|
||||
U->>P: Tab
|
||||
P->>E: acceptSuggestion()
|
||||
E-->>U: 建议变为正常文本
|
||||
else Esc 键
|
||||
U->>P: Esc
|
||||
P->>E: rejectSuggestion()
|
||||
E-->>U: 建议消失
|
||||
else 继续输入
|
||||
U->>E: 输入其他字符
|
||||
E->>P: handleKeyDown()
|
||||
P->>E: clearGhostText()
|
||||
end
|
||||
```
|
||||
### 前端
|
||||
- copilotPlugin.ts: ProseMirror Mark系统
|
||||
- 关键函数: scheduleFetch、insertGhostText
|
||||
- Pinia Store状态管理
|
||||
|
||||
## 设计亮点
|
||||
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (500ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
1. 前后端分离
|
||||
2. 低延迟优化:防抖+SSE+AbortController
|
||||
3. ProseMirror Mark系统
|
||||
4. 多种交互方式
|
||||
5. 智能大小限制
|
||||
6. 隐私保护
|
||||
7. 多语言支持
|
||||
8. 主题定制
|
||||
9. 文档处理
|
||||
10. 语音功能
|
||||
|
||||
## 开发指南
|
||||
|
||||
代码风格: Python(4空格,snake_case) JS/TS(2空格,camelCase)
|
||||
测试: pytest
|
||||
构建: npm run build
|
||||
|
||||
### 运行测试
|
||||
|
||||
项目提供完整的测试套件,包括单元测试、集成测试和macOS环境模拟测试:
|
||||
|
||||
```bash
|
||||
# 快速运行单元测试
|
||||
python backend/tests/run_tests.py unit
|
||||
|
||||
# 运行集成测试(需要启动后端服务)
|
||||
python backend/tests/run_tests.py integration
|
||||
|
||||
# 运行macOS环境模拟测试(在非Mac环境测试)
|
||||
python backend/tests/run_tests.py simulate
|
||||
|
||||
# 运行所有测试
|
||||
python backend/tests/run_tests.py all
|
||||
```
|
||||
|
||||
详细测试说明请参考: [测试指南](backend/tests/TESTING_GUIDE.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
OPENAI_API_KEY=ollama
|
||||
OLLAMA_HOST=http://192.168.0.120:11434
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
VLM_MODEL=qwen3-vl:30b
|
||||
+141
-4
@@ -1,4 +1,141 @@
|
||||
OPENAI_API_KEY=ollama
|
||||
OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/
|
||||
OLLAMA_MODEL=gpt-oss:120b
|
||||
VLM_MODEL=qwen3-vl:30b
|
||||
# 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
|
||||
LLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# Pro-tier model (defaults to LLM_MODEL if unset)
|
||||
PRO_LLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# Vision model for OCR
|
||||
VLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# API key for the FastAPI app (change in production)
|
||||
API_KEY=your-secret-key-here
|
||||
|
||||
# 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
|
||||
|
||||
# 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=4
|
||||
JOB_TTS_MAX_QUEUE=16
|
||||
JOB_ASR_CONCURRENCY=2
|
||||
JOB_ASR_MAX_QUEUE=8
|
||||
|
||||
# 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=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_PRO_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_VISION_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_WEB_SEARCH_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_SPEECH_TTS_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit
|
||||
RISK_SPEECH_ASR_MODEL=Qwen3-ASR-0.6B-8bit
|
||||
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
|
||||
RISK_SPEECH_TTS_MAX_INPUT_CHARS=4096
|
||||
RISK_SPEECH_ASR_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
|
||||
RISK_SPEECH_TTS_INPUT_COST_PER_1K_CHARS=0
|
||||
RISK_SPEECH_TTS_OUTPUT_COST_PER_MINUTE_AUDIO=0
|
||||
RISK_SPEECH_ASR_INPUT_COST_PER_MB=0
|
||||
|
||||
# Shared speech API settings (uses LLM_BASE_URL + LLM_API_KEY)
|
||||
TTS_MODEL_ID=Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit
|
||||
TTS_DEFAULT_INSTRUCTIONS=A clear, natural voice speaking Mandarin Chinese.
|
||||
ASR_MODEL_ID=Qwen3-ASR-0.6B-8bit
|
||||
TTS_ASR_MAX_TEXT_CHARS=4096
|
||||
ASR_MAX_AUDIO_BYTES=104857600
|
||||
TTS_ASR_TTS_TIMEOUT_SECONDS=180
|
||||
TTS_ASR_ASR_TIMEOUT_SECONDS=300
|
||||
TTS_ASR_HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
TTS_ASR_MAX_CONNECTIONS=24
|
||||
TTS_ASR_MAX_KEEPALIVE_CONNECTIONS=12
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Backend 后端指引 (v0.2.0)
|
||||
|
||||
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
|
||||
|
||||
## 后端职责
|
||||
|
||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS/ASR 相关接口。
|
||||
- 组织 Prompt,上下文清洗,调用 OpenAI-compatible 模型接口。
|
||||
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
|
||||
- 负责 API Key 校验、日志记录和队列任务路由。
|
||||
|
||||
## 先看哪里
|
||||
|
||||
- API 入口和路由:main.py
|
||||
- **任务队列系统**:job_system.py(Redis Streams 异步任务管理)
|
||||
- **Worker 进程入口**:worker.py(消费 Redis Streams 的独立 worker)
|
||||
- **任务处理器注册表**:job_handlers.py(completion/PRO/web_search/compress/OCR/convert/TTS/ASR 处理器)
|
||||
- **会话管理**:session_store.py(内存 + PostgreSQL 双后端)
|
||||
- **API/LLM 审计日志**:audit_store.py(PostgreSQL 持久化)
|
||||
- **风控配置**:risk_config.py(环境变量驱动的配置数据类)
|
||||
- **风控引擎**:risk_control.py(速率限制、并发控制、熔断器、预算追踪)
|
||||
- **验证码路由**:captcha_api.py(FastAPI router)
|
||||
- **文档存储**:docs_store.py(MIME 类型检测、文本/二进制分类)
|
||||
- **LLM 策略解析**:llm_policy.py(按 job_type 解析模型配置)
|
||||
- Ollama 调用封装:llm.py
|
||||
- Prompt 清洗和拼装:prompt.py
|
||||
- 数据模型:models.py
|
||||
- 地理位置:geoip.py
|
||||
- TTS 路由:tts_asr.py
|
||||
- Prompt 模板:prompts/
|
||||
- 后端测试:tests/
|
||||
|
||||
## 当前接口面
|
||||
|
||||
- POST /v1/completions
|
||||
- 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 延迟注册
|
||||
|
||||
## 请求流转
|
||||
|
||||
### /v1/completions
|
||||
|
||||
- 读取或生成 request_id。
|
||||
- privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。
|
||||
- 调用 prepare_prompt_context 清洗 prefix 和 suffix。
|
||||
- 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。
|
||||
- **通过 job_system.py 提交到 Redis Streams 队列,由 worker.py 消费。**
|
||||
- **成功时返回 JSON:content 和 request_id。**
|
||||
|
||||
### /v1/completions/cancel
|
||||
|
||||
- 通过 request_id 在 ACTIVE_COMPLETIONS 中查找任务。
|
||||
- 未找到返回 not_found。
|
||||
- 已完成返回 already_done。
|
||||
- 仍在执行则调用 task.cancel() 并返回 ok。
|
||||
|
||||
### /v1/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
|
||||
|
||||
- 接收 base64 文件内容和文件名。
|
||||
- 当前允许的扩展名只有 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 请求通过 job_handlers.py tts_handler 处理。**
|
||||
- **ASR 请求通过 job_handlers.py asr_handler 处理。**
|
||||
- **当前实现统一通过 `LLM_BASE_URL` + `LLM_API_KEY` 调用共享 Speech API,默认模型为 `Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit` 和 `Qwen3-ASR-0.6B-8bit`。**
|
||||
|
||||
## 开发命令
|
||||
|
||||
- 安装依赖: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
|
||||
- pytest backend/tests/test_main_cancel.py -v
|
||||
- Prompt 测试:
|
||||
- pytest backend/tests/test_prompt.py -v
|
||||
- pytest backend/tests/test_prompt_extended.py -v
|
||||
- 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,便于把前后端一次请求串起来。**
|
||||
|
||||
## 容易误判的点
|
||||
|
||||
- **任务队列架构(v0.2.0 新增)**:后端从同步端点转向 Redis Streams 异步队列。job_system.py 定义 JOB_TYPES,worker.py 消费队列,job_handlers.py 注册各类型处理器。
|
||||
- **会话追踪**:session_store.py 提供 InMemorySessionStore(开发)和 PostgresSessionStore(生产),通过 session_hash + ip_hash 追踪请求身份。
|
||||
- **风控系统**:risk_config.py + risk_control.py 实现速率限制(滑动窗口)、并发控制、熔断器模式和预算追踪,所有阈值通过环境变量配置。
|
||||
- **验证码路由**:captcha_api.py 提供 /captcha/generate 和 /captcha/verify 端点,用于前端验证用户输入。
|
||||
- **文档存储**:docs_store.py 提供 MIME 类型检测、文本/二进制分类和预览提取(8MB 限制)。
|
||||
- **LLM 策略解析**:llm_policy.py 按 job_type 解析模型配置(模型名、温度、最大 token、思考级别)。
|
||||
- **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 为准。**
|
||||
- **如果问题是 Web Search 失败,检查 SearXNG 和 Firecrawl 服务状态(docker compose ps)。**
|
||||
- **如果问题是任务队列积压,检查 worker.py 日志和 Redis Streams 长度(INFO keys=stream)。**
|
||||
- **如果问题是会话丢失,检查 session_store.py 的 InMemory/Postgres 后端切换逻辑。**
|
||||
- **如果问题是风控触发,检查 risk_config.py 的环境变量配置和风险阈值。**
|
||||
|
||||
## 测试映射
|
||||
|
||||
- **路由主行为**:tests/test_main_endpoints.py
|
||||
- **取消逻辑**:tests/test_main_cancel.py
|
||||
- **Prompt 逻辑**:tests/test_prompt.py、tests/test_prompt_extended.py
|
||||
- **LLM 包装层**:tests/test_llm.py、tests/test_llm_extended.py
|
||||
- **GeoIP**:tests/test_geoip.py
|
||||
- **TTS 相关**:tests/test_tts_asr_*.py
|
||||
- **网页搜索**:tests/test_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 冲突,以代码为准。**
|
||||
- **新增模块(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/ 目录下的配置保持一致。**
|
||||
@@ -0,0 +1,22 @@
|
||||
ARG DOCKER_REGISTRY_PREFIX=
|
||||
FROM ${DOCKER_REGISTRY_PREFIX}python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg
|
||||
|
||||
COPY backend/requirements.docker.txt /tmp/requirements.docker.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r /tmp/requirements.docker.txt
|
||||
|
||||
COPY backend /app/backend
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 MiB |
@@ -0,0 +1 @@
|
||||
"""Backend package marker for tests and patch targets."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,251 @@
|
||||
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,
|
||||
queue_ms INTEGER NOT NULL DEFAULT 0,
|
||||
run_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_ms INTEGER 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(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS queue_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS run_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS total_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
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)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_request_audit_request_id ON api_request_audit (request_id)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_request_audit_route_created_at ON api_request_audit (route, created_at DESC)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_request_id ON llm_call_audit (request_id)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_job_type_started_at ON llm_call_audit (job_type, started_at DESC)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_model_started_at ON llm_call_audit (model, started_at DESC)"
|
||||
)
|
||||
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, queue_ms, run_ms, total_ms,
|
||||
status, error_code, metadata_json
|
||||
)
|
||||
VALUES (%s, %s, %s, %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)),
|
||||
int(payload.get("queue_ms", 0)),
|
||||
int(payload.get("run_ms", 0)),
|
||||
int(payload.get("total_ms", 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
const path = require('path')
|
||||
const { convert } = require('docx2pdf-converter')
|
||||
|
||||
function main() {
|
||||
const inputPath = process.argv[2]
|
||||
const outputPath = process.argv[3]
|
||||
|
||||
if (!inputPath || !outputPath) {
|
||||
throw new Error('缺少 DOCX 或 PDF 路径')
|
||||
}
|
||||
|
||||
convert(path.resolve(inputPath), path.resolve(outputPath))
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
_geoip_reader = None
|
||||
|
||||
|
||||
def _get_reader():
|
||||
global _geoip_reader
|
||||
if _geoip_reader is not None:
|
||||
return _geoip_reader
|
||||
try:
|
||||
import geoip2.database
|
||||
db_path = os.path.join(os.path.dirname(__file__), "GeoLite2-City.mmdb")
|
||||
if os.path.exists(db_path):
|
||||
_geoip_reader = geoip2.database.Reader(db_path)
|
||||
logger.info("GeoIP database loaded: %s", db_path)
|
||||
return _geoip_reader
|
||||
else:
|
||||
logger.warning("GeoIP database not found: %s", db_path)
|
||||
except ImportError:
|
||||
logger.warning("geoip2 not installed, IP location disabled")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load GeoIP database: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_ip_location(ip: str) -> Optional[dict]:
|
||||
if not ip or ip in ("127.0.0.1", "localhost", "::1"):
|
||||
return None
|
||||
reader = _get_reader()
|
||||
if not reader:
|
||||
return None
|
||||
try:
|
||||
response = reader.city(ip)
|
||||
country = response.country.name
|
||||
region = response.subdivisions.most_specific.name if response.subdivisions else None
|
||||
city = response.city.name
|
||||
parts = [p for p in [country, region, city] if p]
|
||||
if not parts:
|
||||
return None
|
||||
return {
|
||||
"country": country,
|
||||
"region": region,
|
||||
"city": city,
|
||||
"display": " ".join(parts)
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_ip_location_text(ip: str) -> str:
|
||||
loc = get_ip_location(ip)
|
||||
return loc["display"] if loc else ""
|
||||
@@ -0,0 +1,986 @@
|
||||
import asyncio
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import zipfile
|
||||
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
|
||||
from tts_asr import generate_asr_response, generate_tts_response
|
||||
|
||||
|
||||
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").rstrip("/")
|
||||
SEARXNG_RESULT_LIMIT = int(os.getenv("SEARXNG_RESULT_LIMIT", "10") or "10")
|
||||
FIRECRAWL_BASE_URL = os.getenv("FIRECRAWL_BASE_URL", "http://firecrawl:3002").rstrip("/")
|
||||
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "").strip() or ""
|
||||
WEB_SEARCH_QUERY_COUNT = int(os.getenv("WEB_SEARCH_QUERY_COUNT", "4") or "4")
|
||||
WEB_SEARCH_SELECTED_URL_LIMIT = int(os.getenv("WEB_SEARCH_SELECTED_URL_LIMIT", "10") or "10")
|
||||
WEB_SEARCH_CRAWL_CONCURRENCY = max(1, min(6, 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 _looks_like_text(raw_bytes: bytes) -> bool:
|
||||
sample = raw_bytes[:8192]
|
||||
if not sample or b"\x00" in sample:
|
||||
return False
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
if not text.strip():
|
||||
return False
|
||||
control_count = sum(
|
||||
1
|
||||
for char in text
|
||||
if (ord(char) < 32 and char not in "\t\n\r") or ord(char) == 127
|
||||
)
|
||||
return control_count / max(len(text), 1) < 0.05
|
||||
|
||||
|
||||
def _infer_convert_suffix(raw_bytes: bytes, filename: str) -> str:
|
||||
sample = raw_bytes[:1024 * 1024]
|
||||
if sample.startswith(b"%PDF-"):
|
||||
return ".pdf"
|
||||
if sample.startswith((b"PK\x03\x04", b"PK\x05\x06")):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw_bytes)) as archive:
|
||||
names = set(archive.namelist())
|
||||
if any(name.startswith("ppt/") for name in names):
|
||||
return ".pptx"
|
||||
if any(name.startswith("word/") for name in names):
|
||||
return ".docx"
|
||||
except Exception:
|
||||
pass
|
||||
if _looks_like_text(sample):
|
||||
return ".txt"
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_url_addresses(url: str) -> list[tuple[Any, ...]]:
|
||||
parsed = urlparse((url or "").strip())
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return []
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
return socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
|
||||
|
||||
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", ".localhost")):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return not ip.is_global
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
addresses = _resolve_url_addresses(url)
|
||||
except Exception:
|
||||
return True
|
||||
for info in addresses:
|
||||
address = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
continue
|
||||
if not ip.is_global:
|
||||
return True
|
||||
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 await asyncio.to_thread(_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 = "",
|
||||
audit_metadata: dict[str, Any] | None = None,
|
||||
) -> 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)
|
||||
extra_metadata = dict(audit_metadata or {})
|
||||
if profile == "speech_tts":
|
||||
actual_cost = round(
|
||||
(int(extra_metadata.get("text_chars", 0) or 0) / 1000.0) * _risk_config.speech_tts_input_cost_per_1k_chars
|
||||
+ (int(extra_metadata.get("duration_ms", 0) or 0) / 60000.0) * _risk_config.speech_tts_output_cost_per_minute_audio,
|
||||
8,
|
||||
)
|
||||
elif profile == "speech_asr":
|
||||
actual_cost = round(
|
||||
(int(extra_metadata.get("audio_bytes", 0) or 0) / (1024.0 * 1024.0)) * _risk_config.speech_asr_input_cost_per_mb,
|
||||
8,
|
||||
)
|
||||
else:
|
||||
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)
|
||||
job_context = payload.get("job_context") or {}
|
||||
now_ms = int(time.time() * 1000)
|
||||
started_at = int(job_context.get("started_at", 0) or 0)
|
||||
created_at = int(job_context.get("created_at", 0) or 0)
|
||||
queue_ms = int(job_context.get("queue_ms", 0) or 0)
|
||||
run_ms = int(job_context.get("run_ms", 0) or 0)
|
||||
total_ms = int(job_context.get("total_ms", 0) or 0)
|
||||
if not run_ms and started_at:
|
||||
run_ms = max(0, now_ms - started_at)
|
||||
if not total_ms:
|
||||
total_ms = max(0, now_ms - created_at) if created_at else run_ms
|
||||
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,
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": run_ms,
|
||||
"total_ms": total_ms,
|
||||
"metadata": {"profile": profile, **extra_metadata},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 = ""
|
||||
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:
|
||||
raise RuntimeError(f"音频解析失败: {exc}") from 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")
|
||||
try:
|
||||
temp_ext = os.path.splitext(path)[1].lower()
|
||||
except Exception:
|
||||
temp_ext = ""
|
||||
if temp_ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
_safe_unlink(path)
|
||||
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
|
||||
try:
|
||||
if temp_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]:
|
||||
text = str(payload.get("text", "") or "").strip()
|
||||
if not text:
|
||||
raise ValueError("TTS 文本为空")
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
try:
|
||||
response = await generate_tts_response(
|
||||
text=text,
|
||||
instruct=str(payload.get("instruct", "") or ""),
|
||||
speaker=str(payload.get("speaker", "Vivian") or "Vivian"),
|
||||
output_format=str(payload.get("format", "wav") or "wav"),
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
result = dict(response)
|
||||
await emit("result", result)
|
||||
await _exit_llm_execution(
|
||||
payload,
|
||||
identity,
|
||||
risk,
|
||||
lock_keys,
|
||||
status="completed",
|
||||
audit_metadata={
|
||||
"speaker": result.get("speaker", ""),
|
||||
"format": result.get("format", ""),
|
||||
"duration_ms": int(result.get("duration_ms", 0) or 0),
|
||||
"audio_bytes": int(result.get("audio_bytes", 0) or 0),
|
||||
"text_chars": int(result.get("text_chars", len(text)) or len(text)),
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": result.get("upstream_request_id", ""),
|
||||
},
|
||||
)
|
||||
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="tts_failed")
|
||||
raise
|
||||
|
||||
|
||||
async def asr_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:
|
||||
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 = dict(response)
|
||||
await emit("result", result)
|
||||
await _exit_llm_execution(
|
||||
payload,
|
||||
identity,
|
||||
risk,
|
||||
lock_keys,
|
||||
status="completed",
|
||||
actual_output_text=result.get("text", "") or "",
|
||||
audit_metadata={
|
||||
"language": result.get("language", ""),
|
||||
"audio_bytes": int(result.get("audio_bytes", len(audio_bytes)) or len(audio_bytes)),
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": result.get("upstream_request_id", ""),
|
||||
},
|
||||
)
|
||||
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="asr_failed")
|
||||
raise
|
||||
finally:
|
||||
_safe_unlink(path)
|
||||
@@ -0,0 +1,872 @@
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
DEFAULT_CONCURRENCY = {
|
||||
"completion": 2,
|
||||
"pro_completion": 1,
|
||||
"web_search": 1,
|
||||
"compress": 1,
|
||||
"ocr": 1,
|
||||
"convert": 1,
|
||||
"tts": _int_env("JOB_TTS_CONCURRENCY", 2),
|
||||
"asr": _int_env("JOB_ASR_CONCURRENCY", 1),
|
||||
}
|
||||
|
||||
DEFAULT_QUEUE_SIZE = {
|
||||
"completion": 16,
|
||||
"pro_completion": 8,
|
||||
"web_search": 4,
|
||||
"compress": 8,
|
||||
"ocr": 8,
|
||||
"convert": 8,
|
||||
"tts": _int_env("JOB_TTS_MAX_QUEUE", 8),
|
||||
"asr": _int_env("JOB_ASR_MAX_QUEUE", 8),
|
||||
}
|
||||
|
||||
|
||||
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 _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(job_type, config.max_queue)
|
||||
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(),
|
||||
"started_at": 0,
|
||||
"completed_at": 0,
|
||||
"queue_ms": 0,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
}
|
||||
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"],
|
||||
"created_at": job.get("created_at", 0),
|
||||
"started_at": job.get("started_at", 0),
|
||||
"completed_at": job.get("completed_at", 0),
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**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"
|
||||
started_at = _now_ms()
|
||||
job["updated_at"] = started_at
|
||||
job["started_at"] = started_at
|
||||
job["queue_ms"] = max(0, started_at - int(job.get("created_at", started_at)))
|
||||
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"))
|
||||
|
||||
job_payload = dict(job["payload"])
|
||||
job_payload["job_context"] = {
|
||||
"job_id": job_id,
|
||||
"created_at": int(job.get("created_at", 0) or 0),
|
||||
"started_at": int(job.get("started_at", 0) or 0),
|
||||
"queue_ms": int(job.get("queue_ms", 0) or 0),
|
||||
}
|
||||
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
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(
|
||||
job_id,
|
||||
"done",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
async with self.lock:
|
||||
job["status"] = "cancelled"
|
||||
job["cancel_requested"] = True
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
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)
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(
|
||||
job_id,
|
||||
"error",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**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(job_type, config.max_queue)
|
||||
|
||||
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,
|
||||
"started_at": 0,
|
||||
"completed_at": 0,
|
||||
"queue_ms": 0,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
"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",
|
||||
"created_at": int(state.get("created_at", "0") or 0),
|
||||
"started_at": int(state.get("started_at", "0") or 0),
|
||||
"completed_at": int(state.get("completed_at", "0") or 0),
|
||||
"queue_ms": int(state.get("queue_ms", "0") or 0),
|
||||
"run_ms": int(state.get("run_ms", "0") or 0),
|
||||
"total_ms": int(state.get("total_ms", "0") or 0),
|
||||
**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"]
|
||||
started_at = 0
|
||||
created_at = 0
|
||||
queue_ms = 0
|
||||
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)
|
||||
started_at = _now_ms()
|
||||
created_at = int(state.get("created_at", 0) or 0)
|
||||
queue_ms = max(0, started_at - created_at)
|
||||
await self.manager._set_state(job_id, {
|
||||
"job_id": job_id,
|
||||
"request_id": state["request_id"],
|
||||
"type": job_type,
|
||||
"status": "running",
|
||||
"updated_at": started_at,
|
||||
"created_at": created_at or started_at,
|
||||
"started_at": started_at,
|
||||
"completed_at": 0,
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
"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"], {})
|
||||
payload["job_context"] = {
|
||||
"job_id": job_id,
|
||||
"created_at": created_at,
|
||||
"started_at": started_at,
|
||||
"queue_ms": queue_ms,
|
||||
}
|
||||
|
||||
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":
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
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": created_at or started_at,
|
||||
"started_at": started_at,
|
||||
"completed_at": _now_ms(),
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": max(0, _now_ms() - started_at),
|
||||
"total_ms": max(0, _now_ms() - (created_at or started_at)),
|
||||
"cancel_requested": "0",
|
||||
"error": "",
|
||||
"result": _json_dumps(result),
|
||||
})
|
||||
metrics = await self.manager._metrics(job_type)
|
||||
final_state = await self.manager.get_status(job_id) or {}
|
||||
await self.manager._emit_event(
|
||||
job_id,
|
||||
"done",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"queue_ms": final_state.get("queue_ms", queue_ms),
|
||||
"run_ms": final_state.get("run_ms", 0),
|
||||
"total_ms": final_state.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
except asyncio.CancelledError:
|
||||
cancelled_at = _now_ms()
|
||||
await self.manager.redis.hset(
|
||||
self.manager._state_key(job_id),
|
||||
mapping={
|
||||
"status": "cancelled",
|
||||
"cancel_requested": "1",
|
||||
"updated_at": cancelled_at,
|
||||
"completed_at": cancelled_at,
|
||||
"run_ms": max(0, cancelled_at - started_at),
|
||||
"total_ms": max(0, cancelled_at - (created_at or started_at)),
|
||||
},
|
||||
)
|
||||
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": created_at or (_now_ms() if state else _now_ms()),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now_ms(),
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": max(0, _now_ms() - started_at),
|
||||
"total_ms": max(0, _now_ms() - (created_at or started_at)),
|
||||
"cancel_requested": "0",
|
||||
"error": str(exc),
|
||||
})
|
||||
metrics = await self.manager._metrics(job_type)
|
||||
final_state = await self.manager.get_status(job_id) or {}
|
||||
await self.manager._emit_event(
|
||||
job_id,
|
||||
"error",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
"queue_ms": final_state.get("queue_ms", queue_ms),
|
||||
"run_ms": final_state.get("run_ms", 0),
|
||||
"total_ms": final_state.get("total_ms", 0),
|
||||
**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
|
||||
+556
-98
@@ -1,169 +1,627 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
import ollama
|
||||
from typing import AsyncIterator, Literal
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from prompts import get_vlm_ocr_prompt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
# OpenAI-compatible endpoint config
|
||||
LLM_BASE_URL = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1/')
|
||||
LLM_API_KEY = os.getenv('LLM_API_KEY', 'ollama')
|
||||
|
||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||
logger = logging.getLogger("llm")
|
||||
# Auth headers for upstream LLM service (OpenAI-compatible Bearer token)
|
||||
LLM_HEADERS = {'Authorization': f'Bearer {LLM_API_KEY}'}
|
||||
|
||||
VLM_OCR_CONTEXT_PROMPT = """You are an OCR and visual-context extractor for markdown writing assistance.
|
||||
# Model names
|
||||
DEFAULT_LLM_MODEL = 'Nex-N2-mini-mlx-OptiQ-8bit-MTP'
|
||||
_raw_model = os.getenv('LLM_MODEL', DEFAULT_LLM_MODEL)
|
||||
LLM_MODEL = _raw_model.strip() if _raw_model else DEFAULT_LLM_MODEL
|
||||
PRO_LLM_MODEL = os.getenv('PRO_LLM_MODEL', LLM_MODEL)
|
||||
|
||||
Your output will be embedded inside an HTML comment as hidden context for a text-completion model.
|
||||
# VLM for OCR (vision models)
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', DEFAULT_LLM_MODEL)
|
||||
|
||||
Requirements:
|
||||
- Keep output compact: maximum 120 words.
|
||||
- Use plain text only (no markdown code fences).
|
||||
- Never output <!-- or -->.
|
||||
- Do not invent unreadable text; mark uncertain characters with ?.
|
||||
- Preserve original script for recognized text (do not forcibly translate).
|
||||
# Normalize trailing slash for base URL
|
||||
LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
|
||||
|
||||
Return exactly this format:
|
||||
# Timeouts in seconds (10 minutes for large model loading)
|
||||
COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_TIMEOUT", "600"))
|
||||
OCR_TIMEOUT = int(os.getenv("LLM_OCR_TIMEOUT", "600"))
|
||||
|
||||
TEXT:
|
||||
<exact transcription of visible text; use " | " for line breaks; write "(none)" if no readable text>
|
||||
|
||||
KEY_DETAILS:
|
||||
- <3-5 short factual bullets about relevant objects/layout>
|
||||
async def _maybe_await(value):
|
||||
if inspect.isawaitable(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
LANGUAGE:
|
||||
<dominant language(s) in visible text, e.g. English / Chinese / Mixed>
|
||||
|
||||
SUMMARY:
|
||||
<one short sentence, <= 20 words>"""
|
||||
class _AsyncClientContext:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
def _extract_message(response) -> tuple[str, str]:
|
||||
content = ""
|
||||
thinking = ""
|
||||
async def __aenter__(self):
|
||||
return self.client
|
||||
|
||||
if hasattr(response, 'message') and response.message:
|
||||
content = response.message.content or ""
|
||||
thinking = getattr(response.message, 'thinking', '') or ""
|
||||
elif isinstance(response, dict):
|
||||
msg = response.get('message', {})
|
||||
content = msg.get('content', '') or ""
|
||||
thinking = msg.get('thinking', '') or ""
|
||||
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')
|
||||
|
||||
|
||||
def _extract_message(response: dict) -> tuple[str, str]:
|
||||
"""Extract content and thinking from an OpenAI-compatible response dict."""
|
||||
choices = response.get('choices', []) if isinstance(response, dict) else []
|
||||
msg = (choices[0].get('message', {}) if choices and isinstance(choices, list) else {}).copy()
|
||||
content = msg.get('content', '') or ''
|
||||
thinking = (msg.get('reasoning_content') or msg.get('thinking', '') or '').strip()
|
||||
return content, thinking
|
||||
|
||||
|
||||
async def call_ollama(prompt: str, *, tag: str = "default", temperature: float = 0.7) -> dict:
|
||||
"""
|
||||
调用 Ollama API 并返回 content 和 thinking。
|
||||
"""
|
||||
def _resolve_system_prompt(system_prompt: str | None) -> str:
|
||||
if system_prompt and system_prompt.strip():
|
||||
return system_prompt.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
|
||||
candidate = (model or '').strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return PRO_LLM_MODEL if use_pro_model else LLM_MODEL
|
||||
|
||||
|
||||
def _build_chat_payload(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float = 0.7,
|
||||
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)
|
||||
if sys_prompt:
|
||||
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,
|
||||
}
|
||||
if max_output_tokens and max_output_tokens > 0:
|
||||
payload['max_tokens'] = int(max_output_tokens)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _build_chat_stream_payload(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float = 0.7,
|
||||
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)
|
||||
if sys_prompt:
|
||||
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,
|
||||
}
|
||||
if max_output_tokens and max_output_tokens > 0:
|
||||
payload['max_tokens'] = int(max_output_tokens)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_delta_text(chunk: dict) -> str:
|
||||
"""Extract text delta from an OpenAI-compatible SSE chunk."""
|
||||
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
|
||||
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
|
||||
content = delta.get('content', '') or ''
|
||||
return content
|
||||
|
||||
|
||||
def _extract_delta_thinking(chunk: dict) -> str:
|
||||
"""Extract thinking/reasoning delta from an SSE chunk."""
|
||||
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
|
||||
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
|
||||
return (delta.get('reasoning_content') or delta.get('thinking', '') or '').strip()
|
||||
|
||||
|
||||
async def call_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = 'default',
|
||||
temperature: float = 0.7,
|
||||
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()
|
||||
start_dt = datetime.now()
|
||||
|
||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
||||
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] request model=%s host=%s prompt_chars=%d temp=%.2f",
|
||||
tag,
|
||||
OLLAMA_MODEL,
|
||||
OLLAMA_HOST,
|
||||
len(prompt),
|
||||
temperature,
|
||||
'[LLM][%s] request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
payload = _build_chat_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
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:
|
||||
response = await client.chat(
|
||||
model=OLLAMA_MODEL,
|
||||
messages=[{'role': 'user', 'content': prompt}],
|
||||
stream=False,
|
||||
options={
|
||||
'temperature': temperature,
|
||||
'repeat_penalty': 1.1,
|
||||
},
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
resp = await asyncio.wait_for(
|
||||
_client_post(client, '/chat/completions', payload), timeout=COMPLETION_TIMEOUT,
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
response = resp.json()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.warning('[LLM][%s] request cancelled after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] call_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
logger.exception("[LLM][%s] request failed after %.1fms", tag, elapsed_ms)
|
||||
|
||||
logger.exception('[LLM][%s] request failed after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
content, thinking = _extract_message(response)
|
||||
content, thinking_out = _extract_message(response)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] call_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] call_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
||||
tag,
|
||||
elapsed_ms,
|
||||
type(response).__name__,
|
||||
len(content),
|
||||
len(thinking),
|
||||
'[LLM][%s] response in %.1fms content_chars=%d thinking_chars=%d',
|
||||
tag, elapsed_ms, len(content), len(thinking_out or ''),
|
||||
)
|
||||
|
||||
if not content.strip():
|
||||
logger.warning("[LLM][%s] empty content returned by model", tag)
|
||||
logger.warning('[LLM][%s] empty content returned by model', tag)
|
||||
|
||||
return {"content": content, "thinking": thinking}
|
||||
return {'content': content, 'think': thinking_out or ''}
|
||||
|
||||
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
|
||||
async def stream_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = 'default-stream',
|
||||
temperature: float = 0.7,
|
||||
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()
|
||||
start_dt = datetime.now()
|
||||
|
||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
||||
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
|
||||
yielded_chars = 0
|
||||
|
||||
logger.info(
|
||||
"[VLM][ocr] request model=%s host=%s image_bytes=%d language=%s",
|
||||
VLM_MODEL,
|
||||
OLLAMA_HOST,
|
||||
len(image_bytes),
|
||||
language,
|
||||
'[LLM][%s] stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
payload = _build_chat_stream_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
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:
|
||||
response = await client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
await _maybe_await(response.raise_for_status())
|
||||
|
||||
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
||||
line_iterator = await _stream_line_iterator(response)
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError('LLM stream timed out')
|
||||
|
||||
try:
|
||||
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
if not line or line.startswith(':'):
|
||||
continue
|
||||
|
||||
# SSE data lines: "data: {json}" or "data: [DONE]"
|
||||
if line.startswith('data: '):
|
||||
data_str = line[6:] # strip "data: " prefix
|
||||
|
||||
else:
|
||||
data_str = line.strip()
|
||||
|
||||
if not data_str or data_str == '[DONE]':
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('[LLM][%s] ignored invalid stream line', tag)
|
||||
continue
|
||||
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
|
||||
text = _extract_delta_text(chunk)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
yielded_chars += len(text)
|
||||
yield text
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if response is not None:
|
||||
await response.aclose()
|
||||
raise
|
||||
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.warning('[LLM][%s] stream cancelled after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[VLM][ocr] call_time [%s --> %s]",
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
logger.exception("[VLM][ocr] request failed after %.1fms", elapsed_ms)
|
||||
|
||||
logger.exception('[LLM][%s] stream failed after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
content, thinking = _extract_message(response)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"[VLM][ocr] call_time [%s --> %s]",
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
'[LLM][%s] stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[VLM][ocr] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
|
||||
elapsed_ms,
|
||||
type(response).__name__,
|
||||
len(content),
|
||||
len(thinking),
|
||||
'[LLM][%s] stream finished in %.1fms yielded_chars=%d',
|
||||
tag, elapsed_ms, yielded_chars,
|
||||
)
|
||||
|
||||
|
||||
async def stream_ollama_events(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = 'default-events',
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
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()
|
||||
start_dt = datetime.now()
|
||||
|
||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
||||
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
|
||||
yielded_chars = 0
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] event_stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
|
||||
tag, log_model_name, LLM_BASE_URL, len(prompt),
|
||||
len(system_prompt or ''), temperature, thinking,
|
||||
)
|
||||
|
||||
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, prefill=prefill,
|
||||
max_output_tokens=max_output_tokens,
|
||||
)
|
||||
|
||||
effective_timeout = timeout if timeout is not None else COMPLETION_TIMEOUT
|
||||
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
|
||||
sent_thinking = False
|
||||
|
||||
try:
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
await _maybe_await(response.raise_for_status())
|
||||
|
||||
deadline = time.perf_counter() + effective_timeout
|
||||
line_iterator = await _stream_line_iterator(response)
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError('LLM event stream timed out')
|
||||
|
||||
try:
|
||||
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
if not line or line.startswith(':'):
|
||||
continue
|
||||
|
||||
# SSE data lines: "data: {json}" or "data: [DONE]"
|
||||
if line.startswith('data: '):
|
||||
data_str = line[6:] # strip "data: " prefix
|
||||
|
||||
else:
|
||||
data_str = line.strip()
|
||||
|
||||
if not data_str or data_str == '[DONE]':
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('[LLM][%s] ignored invalid Ollama stream line', tag)
|
||||
continue
|
||||
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
|
||||
error = chunk.get('error')
|
||||
if error:
|
||||
raise RuntimeError(str(error))
|
||||
|
||||
thinking_delta = _extract_delta_thinking(chunk)
|
||||
if thinking_delta and not sent_thinking:
|
||||
sent_thinking = True
|
||||
yield 'thinking', ''
|
||||
|
||||
text = _extract_delta_text(chunk)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
yielded_chars += len(text)
|
||||
yield 'content', text
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if response is not None:
|
||||
await response.aclose()
|
||||
raise
|
||||
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] event_stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.warning('[LLM][%s] event stream cancelled after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] event_stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.exception('[LLM][%s] event stream failed after %.1fms', tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] event_stream_time [%s --> %s]', tag,
|
||||
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[LLM][%s] event stream finished in %.1fms yielded_chars=%d thinking_seen=%s',
|
||||
tag, elapsed_ms, yielded_chars, sent_thinking,
|
||||
)
|
||||
|
||||
|
||||
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 media_type=%s media_bytes=%d language=%s mime=%s',
|
||||
VLM_MODEL, LLM_BASE_URL, media_type, len(media_bytes), language, mime_type,
|
||||
)
|
||||
|
||||
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': f"{get_vlm_ocr_prompt()}\n\nTarget language hint: {language or 'auto'}"},
|
||||
{
|
||||
'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 await _create_async_client(http_timeout) as client:
|
||||
resp = await asyncio.wait_for(
|
||||
_client_post(client, '/chat/completions', payload), timeout=OCR_TIMEOUT,
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
response = resp.json()
|
||||
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
|
||||
end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.exception('[VLM][ocr] request failed after %.1fms', elapsed_ms)
|
||||
raise
|
||||
|
||||
content, _ = _extract_message(response)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
|
||||
logger.info(
|
||||
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
|
||||
end_dt.strftime('%H:%M:%S'),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[VLM][ocr] response in %.1fms content_chars=%d', elapsed_ms, len(content),
|
||||
)
|
||||
|
||||
if not content.strip():
|
||||
logger.warning("[VLM][ocr] empty content returned by model")
|
||||
logger.warning('[VLM][ocr] empty content returned by model')
|
||||
|
||||
return content
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
)
|
||||
if job_type == "tts":
|
||||
return LLMPolicy(
|
||||
job_type=job_type,
|
||||
model=config.speech_tts_model,
|
||||
profile="speech_tts",
|
||||
max_input_chars=config.speech_tts_max_input_chars,
|
||||
max_output_tokens=0,
|
||||
temperature=0.0,
|
||||
thinking=None,
|
||||
)
|
||||
if job_type == "asr":
|
||||
return LLMPolicy(
|
||||
job_type=job_type,
|
||||
model=config.speech_asr_model,
|
||||
profile="speech_asr",
|
||||
max_input_chars=config.speech_asr_max_input_bytes,
|
||||
max_output_tokens=0,
|
||||
temperature=0.0,
|
||||
thinking=None,
|
||||
)
|
||||
raise ValueError(f"unsupported llm policy job type: {job_type}")
|
||||
+974
-65
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""共享的 Pydantic 模型定义"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
"""用户偏好设置"""
|
||||
language: str = "auto"
|
||||
country: str = "auto"
|
||||
timezone: str = "auto"
|
||||
@@ -0,0 +1,377 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
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")
|
||||
|
||||
PRO_COMPLETION_TIMEOUT = float(os.getenv("PRO_COMPLETION_TIMEOUT", "3600"))
|
||||
PRO_QUEUE_TIMEOUT = float(os.getenv("PRO_QUEUE_TIMEOUT", "600"))
|
||||
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."
|
||||
|
||||
|
||||
class ProCompletionRequest(BaseModel):
|
||||
prefix: str
|
||||
suffix: str
|
||||
languageId: str = "markdown"
|
||||
instruction: str = ""
|
||||
pro_thinking: str = "medium"
|
||||
privacy_mode: bool = False
|
||||
user_preferences: Optional[UserPreferences] = None
|
||||
|
||||
|
||||
class ProCancelRequest(BaseModel):
|
||||
request_id: str
|
||||
reason: str = "abort"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProRequestState:
|
||||
request_id: str
|
||||
status: str = "queued"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
error: str = ""
|
||||
task: asyncio.Task | None = None
|
||||
cancel_requested: bool = False
|
||||
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
|
||||
def touch(self, status: str | None = None, error: str = "") -> None:
|
||||
if status:
|
||||
self.status = status
|
||||
if error:
|
||||
self.error = error
|
||||
self.updated_at = time.time()
|
||||
|
||||
def request_cancel(self) -> None:
|
||||
self.cancel_requested = True
|
||||
self.touch("cancelled")
|
||||
|
||||
|
||||
PRO_STATES: dict[str, ProRequestState] = {}
|
||||
PRO_STATES_LOCK = asyncio.Lock()
|
||||
PRO_SEMAPHORE = asyncio.Semaphore(PRO_MAX_CONCURRENCY)
|
||||
|
||||
|
||||
def _iso_timestamp(value: float) -> str:
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _clamp_thinking(value: str | None) -> str | None:
|
||||
normalized = (value or "medium").strip().lower()
|
||||
if normalized in {"none", "off", "false"}:
|
||||
return None
|
||||
if normalized in {"low", "medium", "high"}:
|
||||
return normalized
|
||||
return "medium"
|
||||
|
||||
|
||||
def _queued_states() -> list[ProRequestState]:
|
||||
return [state for state in PRO_STATES.values() if state.status == "queued"]
|
||||
|
||||
|
||||
def _queue_position(request_id: str) -> int | None:
|
||||
queued = sorted(_queued_states(), key=lambda item: item.created_at)
|
||||
for index, state in enumerate(queued, start=1):
|
||||
if state.request_id == request_id:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
async def _cleanup_states() -> None:
|
||||
now = time.time()
|
||||
expired = [
|
||||
request_id
|
||||
for request_id, state in PRO_STATES.items()
|
||||
if state.status in {"done", "error", "cancelled"}
|
||||
and now - state.updated_at > PRO_STATUS_RETENTION_SECONDS
|
||||
]
|
||||
for request_id in expired:
|
||||
PRO_STATES.pop(request_id, None)
|
||||
|
||||
|
||||
def _state_payload(state: ProRequestState) -> dict:
|
||||
return {
|
||||
"request_id": state.request_id,
|
||||
"status": state.status,
|
||||
"queue_position": _queue_position(state.request_id),
|
||||
"created_at": _iso_timestamp(state.created_at),
|
||||
"updated_at": _iso_timestamp(state.updated_at),
|
||||
"error": state.error,
|
||||
}
|
||||
|
||||
|
||||
def _build_pro_prompts(
|
||||
*,
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
language_id: str,
|
||||
instruction: str,
|
||||
pro_thinking: str = "medium",
|
||||
location: str = "",
|
||||
preferences: UserPreferences | None = None,
|
||||
) -> tuple[str, str]:
|
||||
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:
|
||||
if request.client:
|
||||
return request.headers.get("X-Client-IP") or request.client.host
|
||||
return request.headers.get("X-Client-IP") or "unknown"
|
||||
|
||||
|
||||
async def _send_sse_event(queue: asyncio.Queue, event_name: str, data: dict) -> None:
|
||||
await queue.put((event_name, json.dumps(data, ensure_ascii=False)))
|
||||
|
||||
|
||||
async def _wait_for_cancel_cleanup(state: ProRequestState, request_tag: str, reason: str) -> None:
|
||||
if state.done_event.is_set():
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[%s] /v1/pro/completions cancel cleanup not confirmed request_id=%s reason=%s",
|
||||
request_tag,
|
||||
state.request_id,
|
||||
reason,
|
||||
)
|
||||
|
||||
|
||||
def register_pro_completion_routes(app: FastAPI, get_api_key):
|
||||
@app.post("/v1/pro/completions")
|
||||
async def create_pro_completion(
|
||||
request: Request,
|
||||
req: ProCompletionRequest,
|
||||
api_key: str = Security(get_api_key),
|
||||
):
|
||||
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
request_tag = request_id[:8]
|
||||
event_queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
|
||||
previous_state: ProRequestState | None = None
|
||||
|
||||
async with PRO_STATES_LOCK:
|
||||
await _cleanup_states()
|
||||
queued_count = len(_queued_states())
|
||||
if queued_count >= PRO_QUEUE_MAX_SIZE:
|
||||
logger.info("[%s] /v1/pro/completions rejected queue_full request_id=%s", request_tag, request_id)
|
||||
return JSONResponse(
|
||||
content={"error": "PRO queue is full", "request_id": request_id},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
existing = PRO_STATES.get(request_id)
|
||||
if existing and existing.task and not existing.task.done():
|
||||
existing.request_cancel()
|
||||
existing.task.cancel()
|
||||
previous_state = existing
|
||||
|
||||
state = ProRequestState(request_id=request_id)
|
||||
PRO_STATES[request_id] = state
|
||||
|
||||
if previous_state:
|
||||
await _wait_for_cancel_cleanup(previous_state, request_tag, "replace")
|
||||
|
||||
client_ip = "hidden"
|
||||
location = ""
|
||||
if not req.privacy_mode: # pragma: no cover
|
||||
client_ip = _get_client_ip(request)
|
||||
location = get_ip_location_text(client_ip)
|
||||
|
||||
prefix = req.prefix or ""
|
||||
suffix = req.suffix or ""
|
||||
|
||||
system_prompt, user_prompt = _build_pro_prompts(
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
language_id=req.languageId,
|
||||
instruction=req.instruction,
|
||||
pro_thinking=req.pro_thinking,
|
||||
location=location,
|
||||
preferences=req.user_preferences,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/pro/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d instruction_chars=%d lang=%s thinking=%s",
|
||||
request_tag,
|
||||
request_id,
|
||||
client_ip,
|
||||
len(prefix),
|
||||
len(suffix),
|
||||
len(req.instruction or ""),
|
||||
req.languageId,
|
||||
req.pro_thinking,
|
||||
)
|
||||
|
||||
async def producer() -> None:
|
||||
acquired = False
|
||||
chunks: list[str] = []
|
||||
try:
|
||||
async with PRO_STATES_LOCK:
|
||||
if state.cancel_requested:
|
||||
raise asyncio.CancelledError()
|
||||
state.touch("queued")
|
||||
queue_position = _queue_position(request_id)
|
||||
await _send_sse_event(event_queue, "queued", {"request_id": request_id, "queue_position": queue_position})
|
||||
|
||||
await asyncio.wait_for(PRO_SEMAPHORE.acquire(), timeout=PRO_QUEUE_TIMEOUT)
|
||||
acquired = True
|
||||
|
||||
async with PRO_STATES_LOCK:
|
||||
if state.cancel_requested:
|
||||
raise asyncio.CancelledError()
|
||||
state.touch("started")
|
||||
await _send_sse_event(event_queue, "started", {"request_id": request_id})
|
||||
|
||||
async for event_type, payload in stream_ollama_events(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f"{request_tag}-pro",
|
||||
temperature=0.7,
|
||||
thinking=_clamp_thinking(req.pro_thinking),
|
||||
use_pro_model=True,
|
||||
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:
|
||||
raise asyncio.CancelledError()
|
||||
if not content:
|
||||
raise ValueError("PRO returned empty content")
|
||||
|
||||
async with PRO_STATES_LOCK:
|
||||
state.touch("done")
|
||||
logger.info("[%s] /v1/pro/completions done request_id=%s content_chars=%d", request_tag, request_id, len(content))
|
||||
await _send_sse_event(event_queue, "done", {"content": content, "request_id": request_id})
|
||||
except asyncio.CancelledError:
|
||||
async with PRO_STATES_LOCK:
|
||||
state.request_cancel()
|
||||
logger.info("[%s] /v1/pro/completions cancelled request_id=%s", request_tag, request_id)
|
||||
await _send_sse_event(event_queue, "cancelled", {"cancelled": True, "request_id": request_id})
|
||||
raise
|
||||
except Exception as exc:
|
||||
async with PRO_STATES_LOCK:
|
||||
state.touch("error", PUBLIC_PRO_ERROR)
|
||||
logger.exception("[%s] /v1/pro/completions failed request_id=%s", request_tag, request_id)
|
||||
await _send_sse_event(event_queue, "error", {"error": PUBLIC_PRO_ERROR, "request_id": request_id})
|
||||
finally:
|
||||
if acquired:
|
||||
PRO_SEMAPHORE.release()
|
||||
state.done_event.set()
|
||||
await event_queue.put(None)
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
async with PRO_STATES_LOCK:
|
||||
state.task = producer_task
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
while True:
|
||||
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()
|
||||
producer_task.cancel()
|
||||
raise
|
||||
finally:
|
||||
if not producer_task.done() and not state.done_event.is_set():
|
||||
async with PRO_STATES_LOCK:
|
||||
state.request_cancel()
|
||||
producer_task.cancel()
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream; charset=utf-8",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/v1/pro/completions/cancel")
|
||||
async def cancel_pro_completion(req: ProCancelRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = req.request_id or ""
|
||||
request_tag = request_id[:8]
|
||||
state_to_wait: ProRequestState | None = None
|
||||
async with PRO_STATES_LOCK:
|
||||
await _cleanup_states()
|
||||
state = PRO_STATES.get(request_id)
|
||||
if not state:
|
||||
return {"cancelled": False, "status": "not_found"}
|
||||
if state.task and not state.task.done():
|
||||
state.request_cancel()
|
||||
state.task.cancel()
|
||||
state_to_wait = state
|
||||
if state.status in {"done", "error", "cancelled"}:
|
||||
if not state_to_wait:
|
||||
return {"cancelled": False, "status": state.status}
|
||||
else:
|
||||
state.request_cancel()
|
||||
|
||||
if state_to_wait:
|
||||
await _wait_for_cancel_cleanup(state_to_wait, request_tag, req.reason)
|
||||
|
||||
return {"cancelled": True, "status": "ok"}
|
||||
|
||||
@app.get("/v1/pro/completions/status/{request_id}")
|
||||
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
|
||||
async with PRO_STATES_LOCK:
|
||||
await _cleanup_states()
|
||||
state = PRO_STATES.get(request_id)
|
||||
if not state:
|
||||
raise HTTPException(status_code=404, detail="PRO request not found")
|
||||
return _state_payload(state)
|
||||
+469
-51
@@ -1,5 +1,61 @@
|
||||
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_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:
|
||||
# Default to UTC+8 if auto or not specified.
|
||||
offset = 8
|
||||
tz_info = " (UTC+8)"
|
||||
|
||||
if timezone_pref and timezone_pref != "auto":
|
||||
# Parse values like "UTC+8" or "GMT-5".
|
||||
match = re.search(r"([+-])(\d+)", timezone_pref)
|
||||
if match:
|
||||
sign = match.group(1)
|
||||
hours = int(match.group(2))
|
||||
offset = hours if sign == "+" else -hours
|
||||
tz_info = f" ({timezone_pref})"
|
||||
else:
|
||||
tz_info = f" ({timezone_pref})"
|
||||
|
||||
now = datetime.now(timezone(timedelta(hours=offset)))
|
||||
weekdays = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
]
|
||||
weekday = weekdays[now.weekday()]
|
||||
return (
|
||||
f"{now.year}-{now.month:02d}-{now.day:02d} "
|
||||
f"{weekday} {now.hour:02d}:{now.minute:02d}:{now.second:02d}{tz_info}"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
@@ -11,72 +67,436 @@ def _sanitize_language_id(language_id: str) -> str:
|
||||
return value or "markdown"
|
||||
|
||||
|
||||
def _normalize_newlines(text: str) -> str:
|
||||
return (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Prepare prefix/suffix for model completion context.
|
||||
Filter out potential web-scraping or legacy artifacts like <br>, <br/>, <br\\>.
|
||||
"""
|
||||
return prefix, suffix
|
||||
br_pattern = re.compile(r"<br\s*/?\s*\\?>", re.IGNORECASE)
|
||||
clean_prefix = br_pattern.sub("", prefix or "")
|
||||
clean_suffix = br_pattern.sub("", suffix or "")
|
||||
return clean_prefix, clean_suffix
|
||||
|
||||
|
||||
def _strip_hidden_tail_context(text: str) -> str:
|
||||
"""
|
||||
Return the likely visible tail segment used for prefill.
|
||||
|
||||
The frontend prepends hidden OCR/doc context before the visible markdown and
|
||||
joins those blocks with blank lines. For prefill we only want the active
|
||||
visible segment near the cursor, not earlier hidden context.
|
||||
"""
|
||||
value = _normalize_newlines(text or "")
|
||||
if not value:
|
||||
return ""
|
||||
tail = re.split(r"\n{2,}", value)[-1]
|
||||
tail = re.sub(r"<!--[\s\S]*?-->", "", tail)
|
||||
tail = re.sub(r"<OCR:[^>\n]*>", "", tail)
|
||||
return tail.split("\n")[-1]
|
||||
|
||||
|
||||
def _build_completion_prefill(prefix: str) -> str:
|
||||
"""
|
||||
Build a short tail prefill after <|fim_middle|> so completion models keep
|
||||
writing from the existing text instead of explaining the boundary rules.
|
||||
"""
|
||||
normalized = _normalize_newlines(prefix or "")
|
||||
if not normalized or normalized[-1].isspace():
|
||||
return ""
|
||||
|
||||
tail = _strip_hidden_tail_context(normalized).strip()
|
||||
if len(tail) < 2:
|
||||
return ""
|
||||
|
||||
cjk_match = re.search(r"[\u3400-\u9fff]{2,6}$", tail)
|
||||
if cjk_match:
|
||||
value = cjk_match.group(0)
|
||||
return value[-2:] if len(value) > 2 else value
|
||||
|
||||
token_match = re.search(r"[A-Za-z0-9_+\-.]{2,12}$", tail)
|
||||
if token_match:
|
||||
value = token_match.group(0)
|
||||
return value[-12:]
|
||||
|
||||
compact_match = re.search(r"\S{2,12}$", tail)
|
||||
if compact_match:
|
||||
return compact_match.group(0)[-12:]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$")
|
||||
FENCE_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$")
|
||||
MERMAID_CONTEXT_RE = re.compile(
|
||||
r"```[ \t]*mermaid\b|"
|
||||
r"\b(flowchart|sequencediagram|classdiagram|statediagram(?:-v2)?|"
|
||||
r"erdiagram|journey|gantt|pie|mindmap|timeline|gitgraph|quadrantchart|xychart-beta)\b|"
|
||||
r"\bgraph[ \t]+(TD|TB|BT|RL|LR)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _cursor_in_fenced_code_block(prefix: str) -> bool:
|
||||
"""
|
||||
Determine whether the cursor is currently inside a fenced code block.
|
||||
The state is computed by toggling on each markdown fence line that matches:
|
||||
^[ \t]*```.*$
|
||||
"""
|
||||
return _active_fence_language(prefix) != "none"
|
||||
|
||||
|
||||
def _active_fence_language(prefix: str) -> str:
|
||||
"""
|
||||
Return active fence language at cursor based on prefix.
|
||||
- "none": cursor is outside fenced code block
|
||||
- "unknown": cursor is inside a fence without language tag
|
||||
- "<language>": cursor is inside a fenced block with language tag
|
||||
"""
|
||||
normalized = _normalize_newlines(prefix)
|
||||
in_fence = False
|
||||
active_language = "none"
|
||||
for line in normalized.split("\n"):
|
||||
if FENCE_LINE_RE.match(line):
|
||||
if in_fence:
|
||||
in_fence = False
|
||||
active_language = "none"
|
||||
else:
|
||||
info_match = FENCE_INFO_RE.match(line)
|
||||
info = info_match.group(1).strip() if info_match else ""
|
||||
if not info:
|
||||
active_language = "unknown"
|
||||
else:
|
||||
first_token = info.split()[0]
|
||||
lang_chars = []
|
||||
for ch in first_token.strip():
|
||||
if ch.isalnum() or ch in "-_+.":
|
||||
lang_chars.append(ch)
|
||||
active_language = "".join(lang_chars)[:32].lower() or "unknown"
|
||||
in_fence = True
|
||||
return active_language if in_fence else "none"
|
||||
|
||||
|
||||
def _is_mermaid_context(prefix: str, suffix: str, cursor_fence_language: str) -> bool:
|
||||
if cursor_fence_language == "mermaid":
|
||||
return True
|
||||
|
||||
prefix_tail = (prefix or "")[-1200:]
|
||||
suffix_head = (suffix or "")[:400]
|
||||
combined = f"{prefix_tail}\n{suffix_head}"
|
||||
return MERMAID_CONTEXT_RE.search(combined) is not None
|
||||
|
||||
|
||||
def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]:
|
||||
return _prepare_context(prefix, suffix)
|
||||
|
||||
|
||||
def build_prompt(prefix: str, suffix: str, language_id: str = "markdown") -> str:
|
||||
safe_language_id = _sanitize_language_id(language_id)
|
||||
LANGUAGE_SYNONYMS = {
|
||||
"md": "markdown",
|
||||
"markdown": "markdown",
|
||||
"txt": "text",
|
||||
"text": "text",
|
||||
"plain": "text",
|
||||
"plaintext": "text",
|
||||
"py": "python",
|
||||
"python": "python",
|
||||
"js": "javascript",
|
||||
"javascript": "javascript",
|
||||
"jsx": "javascript",
|
||||
"node": "javascript",
|
||||
"ts": "typescript",
|
||||
"tsx": "typescript",
|
||||
"typescript": "typescript",
|
||||
"json": "json",
|
||||
"jsonc": "json",
|
||||
"json5": "json",
|
||||
"yaml": "yaml",
|
||||
"yml": "yaml",
|
||||
"toml": "toml",
|
||||
"ini": "ini",
|
||||
"cfg": "ini",
|
||||
"bash": "bash",
|
||||
"shell": "bash",
|
||||
"sh": "bash",
|
||||
"zsh": "bash",
|
||||
"fish": "bash",
|
||||
"ps": "powershell",
|
||||
"ps1": "powershell",
|
||||
"powershell": "powershell",
|
||||
"sql": "sql",
|
||||
"postgres": "sql",
|
||||
"postgresql": "sql",
|
||||
"mysql": "sql",
|
||||
"sqlite": "sql",
|
||||
"html": "html",
|
||||
"xml": "xml",
|
||||
"svg": "xml",
|
||||
"css": "css",
|
||||
"scss": "css",
|
||||
"less": "css",
|
||||
"latex": "latex",
|
||||
"tex": "latex",
|
||||
"katex": "latex",
|
||||
"mermaid": "mermaid",
|
||||
"c": "c",
|
||||
"c++": "cpp",
|
||||
"cpp": "cpp",
|
||||
"cxx": "cpp",
|
||||
"h": "c",
|
||||
"hpp": "cpp",
|
||||
"c#": "csharp",
|
||||
"cs": "csharp",
|
||||
"csharp": "csharp",
|
||||
"go": "go",
|
||||
"golang": "go",
|
||||
"rust": "rust",
|
||||
"rs": "rust",
|
||||
"java": "java",
|
||||
"kotlin": "kotlin",
|
||||
"swift": "swift",
|
||||
"ruby": "ruby",
|
||||
"rb": "ruby",
|
||||
"php": "php",
|
||||
"lua": "lua",
|
||||
"r": "r",
|
||||
"matlab": "matlab",
|
||||
"dart": "dart",
|
||||
"docker": "dockerfile",
|
||||
"dockerfile": "dockerfile",
|
||||
"make": "makefile",
|
||||
"makefile": "makefile",
|
||||
"diff": "diff",
|
||||
"patch": "diff",
|
||||
"regex": "regex",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_language_id(language_id: str) -> str:
|
||||
safe = _sanitize_language_id(language_id).lower()
|
||||
if not safe:
|
||||
return "markdown"
|
||||
return LANGUAGE_SYNONYMS.get(safe, safe)
|
||||
|
||||
|
||||
_JS_LANGS = {"javascript", "typescript"}
|
||||
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
|
||||
|
||||
|
||||
def _language_guidance(language_id: str) -> str:
|
||||
canonical = _canonical_language_id(language_id)
|
||||
if canonical == "markdown":
|
||||
return ""
|
||||
guidance_map = get_language_guidance_map()
|
||||
guidance = guidance_map.get(canonical)
|
||||
if guidance:
|
||||
return guidance
|
||||
if canonical in _JS_LANGS:
|
||||
return guidance_map.get("_js_code", "").replace("{lang}", canonical)
|
||||
if canonical in _CODE_LANGS:
|
||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||
|
||||
|
||||
def build_inline_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_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()
|
||||
|
||||
|
||||
_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(
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
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)
|
||||
recent_suffix = _normalize_newlines(recent_suffix)
|
||||
|
||||
prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions.
|
||||
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")
|
||||
prefill = _build_completion_prefill(recent_prefix)
|
||||
|
||||
Your job:
|
||||
- Return ONLY the text that should be inserted at the cursor between PREFIX and SUFFIX.
|
||||
- Prefer a meaningful, non-empty insertion with moderate length.
|
||||
- Avoid overly short outputs with little information value.
|
||||
tz_pref = preferences.timezone if preferences else "auto"
|
||||
current_time = _get_current_datetime(tz_pref)
|
||||
location_info = f"\nUser location: {location}" if location else ""
|
||||
|
||||
Important context:
|
||||
- PREFIX may contain OCR metadata inline after images, e.g.  <OCR:description>.
|
||||
- The <OCR:...> is hidden context describing image content.
|
||||
- Never copy, rewrite, or emit OCR tags in output.
|
||||
- Never output <OCR: or >.
|
||||
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}")
|
||||
|
||||
Hard rules:
|
||||
1. Seamless join:
|
||||
PREFIX + OUTPUT + SUFFIX must read naturally as one continuous document.
|
||||
2. No suffix repetition:
|
||||
Do NOT repeat text that already appears at the start of SUFFIX.
|
||||
3. Balanced length:
|
||||
Prefer concise but meaningful continuation, not ultra-short fragments.
|
||||
Default target is 20-120 characters and 1-3 lines for plain prose.
|
||||
You may be longer when structure requires it (lists, tables, code blocks, math blocks).
|
||||
4. Avoid trivial output:
|
||||
Do not output only punctuation or filler such as ".", ",", ";", ":".
|
||||
Do not output just one token unless it is structurally necessary.
|
||||
5. Preserve local style:
|
||||
Match nearby language, tone, punctuation, spacing, and indentation.
|
||||
6. Markdown awareness:
|
||||
Continue active list/checkbox/ordered-list patterns when applicable.
|
||||
Preserve indentation in nested list/code contexts.
|
||||
You may output full markdown structures when context needs them: headings, lists, tables, fenced code blocks, blockquotes, and LaTeX ($...$ / $$...$$).
|
||||
Close obvious unclosed inline markdown markers only when needed to bridge.
|
||||
7. Strict output format:
|
||||
Output insertion text only.
|
||||
No explanations, labels, or wrapper quotes around the whole output.
|
||||
Markdown syntax is allowed when it is the intended insertion (including fenced code blocks and LaTeX).
|
||||
preferences_instruction = "\n".join(pref_info)
|
||||
if preferences_instruction:
|
||||
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
|
||||
|
||||
Decision policy:
|
||||
- If PREFIX already connects naturally to SUFFIX, add a brief but useful continuation when possible.
|
||||
- If uncertain, prefer a complete short phrase or sentence with clear meaning.
|
||||
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
|
||||
Reasoning level: {thinking_level}
|
||||
Editor language: {safe_language_id}
|
||||
|
||||
Examples:
|
||||
<PREFIX>The quick brown fox </PREFIX>
|
||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
||||
Output: "moved quietly and then "
|
||||
=== 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"}
|
||||
|
||||
<PREFIX>## TODO\\n- [ ] Buy milk\\n- [ ] </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Output: "Write release notes and share draft with team"
|
||||
=== TASK ===
|
||||
Produce the best insertion text between PREFIX and SUFFIX.
|
||||
Requirements:
|
||||
- Non-empty and meaningful
|
||||
- Concise unless structure needs more
|
||||
- Follows markdown rules in system prompt
|
||||
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
|
||||
- If a boundary needs separation, put the real newline directly in OUTPUT
|
||||
- Do not explain newline or boundary choices
|
||||
- Continue after the PREFILL text already placed after <|fim_middle|>
|
||||
|
||||
Now produce the insertion.
|
||||
=== CONTEXT NOTES ===
|
||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
||||
- Match PREFIX tone, style, and indentation
|
||||
- Do not repeat text from SUFFIX beginning
|
||||
- <|fim_prefix|>, <|fim_suffix|>, <|fim_middle|>, and PREFILL are control context only; never output these markers
|
||||
|
||||
=== EXAMPLES BY CATEGORY ===
|
||||
{_INLINE_EXAMPLES}
|
||||
|
||||
=== NOW COMPLETE THE TASK ===
|
||||
|
||||
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>{prefill}"""
|
||||
|
||||
system_prompt = build_inline_system_prompt(safe_language_id)
|
||||
return system_prompt.strip(), user_prompt.strip(), prefill
|
||||
|
||||
|
||||
def build_prompt(
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: UserPreferences | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Backward-compatible helper. Returns only the user prompt body.
|
||||
"""
|
||||
_, user_prompt, _ = build_completion_prompts(
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
language_id=language_id,
|
||||
location=location,
|
||||
thinking_level=thinking_level,
|
||||
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}
|
||||
@@ -88,7 +508,5 @@ Now produce the insertion.
|
||||
|
||||
Output:"""
|
||||
|
||||
return prompt.strip()
|
||||
|
||||
|
||||
|
||||
system_prompt = build_pro_system_prompt(safe_language_id)
|
||||
return system_prompt.strip(), user_prompt.strip()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
class PromptManager:
|
||||
_instance = None
|
||||
_data: dict[str, Any] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._load_all()
|
||||
return cls._instance
|
||||
|
||||
def _load_all(self):
|
||||
for json_file in _PROMPTS_DIR.glob("*.json"):
|
||||
key = json_file.stem
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
self._data[key] = json.load(f)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
||||
_prompts = PromptManager()
|
||||
|
||||
|
||||
def get_system_prompt_template() -> str:
|
||||
return _prompts.get("system_prompt", {}).get("template", "")
|
||||
|
||||
|
||||
def get_language_guidance_map() -> dict[str, str]:
|
||||
return _prompts.get("language_guidance", {})
|
||||
|
||||
|
||||
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", "")
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"content": "=== CATEGORY A: PROSE CONTINUATION ===\n\n[EX01] Simple prose continuation\n<PREFIX>The quick brown fox </PREFIX>\n<SUFFIX>jumps over the lazy dog.</SUFFIX>\nExpected OUTPUT:\nmoved quietly and then\n\n[EX02] Avoid repeating suffix\n<PREFIX>Our launch plan starts with </PREFIX>\n<SUFFIX>phase one, followed by phase two.</SUFFIX>\nExpected OUTPUT:\ncareful internal testing before\nWRONG: phase one starts with (repeats suffix)\n\n=== CATEGORY B: MARKDOWN STRUCTURES ===\n\n[EX03] Continue checklist\n<PREFIX>## TODO\n- [ ] Buy milk\n- [ ] </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\nWrite release notes and share draft with team\n\n[EX04] Start list after header (PREFIX lacks newline)\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>Deployment steps:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n- Build artifact\n- Deploy service\n\n[EX05] Continue table row\n<PREFIX>| Name | Score |\n| --- | --- |\n| Alice | 92 |\n| Bob | </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n88 |\n\n[EX06] Start new paragraph\n<PREFIX>First paragraph ends.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nSecond paragraph starts.\nWRONG: Second paragraph starts. (missing leading \\n\\n)\n\n[EX07] Add newline before heading\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>End of previous section.</PREFIX>\n<SUFFIX>## Next Heading</SUFFIX>\nExpected OUTPUT:\n\nWRONG: (would join with heading without separation)\n\n=== CATEGORY C: CODE BLOCKS ===\n\n[EX08] Outside fence: wrap code in fence\nCURSOR_IN_FENCED_CODE_BLOCK=false\n<PREFIX>Parse this JSON payload in Python:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```python\nimport json\ndata = json.loads(payload)\n```\nWRONG: import json\\ndata = json.loads(payload) (no fence)\n\n[EX09] Inside fence: output code only\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef add(a, b):\n return </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\na + b\nWRONG: ```python\\nreturn a + b\\n``` (duplicate fences)\n\n[EX10] Code inside fence uses single newline\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef hello():</PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\n print(\"Hello\")\n return True\n(Note: single \\n between code lines, no markdown rules)\n\n=== CATEGORY D: MATH ===\n\n[EX11] Inline math\n<PREFIX>The derivative of x^2 is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$2x$\nWRONG: 2x (bare formula)\n\n[EX12] Block math\n<PREFIX>We can write the Gaussian integral as:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}\n$$\nWRONG: \\int... (bare formula without $$)\n\n=== CATEGORY E: MERMAID ===\n\n[EX13] Inside mermaid fence\nCURSOR_FENCE_LANGUAGE=mermaid\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```mermaid\nflowchart TD\n A[Start] --> </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nB{Valid?}\nB -->|Yes| C[Done]\nWRONG: ```mermaid\\nB{Valid?}... (duplicate fence)\n\n[EX14] Outside fence with mermaid context\nCURSOR_IN_FENCED_CODE_BLOCK=false\nMERMAID_CONTEXT=true\n<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```mermaid\nflowchart LR\n Build --> Test --> Deploy\n```\n\n=== CATEGORY F: OCR METADATA ===\n\n[EX15] Use OCR as context, never output\n<PREFIX> <OCR:equation y = mx + b>\nThe relationship is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$y = mx + b$\nWRONG: <OCR:equation y = mx + b> (OCR tag in output)"
|
||||
}
|
||||
@@ -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> <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```"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mermaid": "\nLanguage-specific guidance (mermaid):\n- Output valid Mermaid syntax only.\n- Prefer concise, syntactically correct diagram statements.\n- Avoid prose unless the user prompt explicitly requires it.",
|
||||
"latex": "\nLanguage-specific guidance (latex):\n- Output LaTeX math content only when completing LaTeX.\n- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:\n- Output raw LaTeX lines only.\n- Do not wrap with $ or $$.",
|
||||
"json": "\nLanguage-specific guidance (json):\n- Output strict JSON only (no comments, no trailing commas).\n- Ensure valid quotes and braces.",
|
||||
"yaml": "\nLanguage-specific guidance (yaml):\n- Output valid YAML only.\n- Use consistent indentation and avoid tabs.",
|
||||
"toml": "\nLanguage-specific guidance (toml):\n- Output valid TOML only.\n- Keep key types consistent.",
|
||||
"ini": "\nLanguage-specific guidance (ini):\n- Output valid INI only.\n- Keep section headers and key=value pairs consistent.",
|
||||
"sql": "\nLanguage-specific guidance (sql):\n- Output a single, valid SQL statement unless context requires multiple.\n- Prefer ANSI SQL when dialect is unclear.",
|
||||
"bash": "\nLanguage-specific guidance (bash):\n- Output POSIX-compatible shell when possible.\n- Avoid interactive prompts or destructive commands unless requested.",
|
||||
"powershell": "\nLanguage-specific guidance (powershell):\n- Output valid PowerShell commands.\n- Avoid destructive commands unless explicitly requested.",
|
||||
"html": "\nLanguage-specific guidance (html):\n- Output valid HTML only.\n- Keep markup minimal and well-formed.",
|
||||
"css": "\nLanguage-specific guidance (css):\n- Output valid CSS only.\n- Use concise, readable selectors.",
|
||||
"diff": "\nLanguage-specific guidance (diff):\n- Output a unified diff only.\n- Ensure @@ hunk headers and +/- lines are consistent.",
|
||||
"regex": "\nLanguage-specific guidance (regex):\n- Output the regex pattern only.\n- Avoid delimiters unless explicitly requested.",
|
||||
"text": "\nLanguage-specific guidance (text):\n- Output plain text only.\n- Avoid markdown formatting unless explicitly asked.",
|
||||
"xml": "\nLanguage-specific guidance (xml):\n- Output well-formed XML only.\n- Ensure matching tags and proper escaping.",
|
||||
"dockerfile": "\nLanguage-specific guidance (dockerfile):\n- Output valid Dockerfile instructions only.\n- Keep layers minimal and ordered logically.",
|
||||
"makefile": "\nLanguage-specific guidance (makefile):\n- Output valid Makefile syntax only.\n- Use tabs for recipe lines.",
|
||||
"_generic_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Avoid prose unless context clearly expects comments or docstrings.",
|
||||
"_js_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Prefer modern syntax and avoid prose unless comments are needed."
|
||||
}
|
||||
@@ -0,0 +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.\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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"prompt": "You are an OCR and visual-context extractor for markdown writing assistance.\n\nYour output will be embedded inside an HTML comment as hidden context for a text-completion model.\n\nRequirements:\n- Keep output compact: maximum 120 words.\n- Use plain text only (no markdown code fences).\n- Never output <!-- or -->.\n- Do not invent unreadable text; mark uncertain characters with ?.\n- Preserve original script for recognized text (do not forcibly translate).\n\nReturn exactly this format:\n\nTEXT:\n<exact transcription of visible text; use \" | \" for line breaks; write \"(none)\" if no readable text>\n\nKEY_DETAILS:\n- <3-5 short factual bullets about relevant objects/layout>\n\nLANGUAGE:\n<dominant language(s) in visible text, e.g. English / Chinese / Mixed>\n\nSUMMARY:\n<one short sentence, <= 20 words>"
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
markitdown>=0.1.1
|
||||
geoip2>=4.8.0
|
||||
@@ -1,6 +1,14 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
ollama
|
||||
pydantic
|
||||
python-dotenv
|
||||
httpx
|
||||
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
|
||||
|
||||
# testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.1.0
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
speech_tts_model: str
|
||||
speech_asr_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
|
||||
speech_tts_max_input_chars: int
|
||||
speech_asr_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
|
||||
speech_tts_input_cost_per_1k_chars: float
|
||||
speech_tts_output_cost_per_minute_audio: float
|
||||
speech_asr_input_cost_per_mb: 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", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
pro_model=_str_env("RISK_PRO_MODEL", os.getenv("PRO_LLM_MODEL", os.getenv("LLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP"))),
|
||||
vision_model=_str_env("RISK_VISION_MODEL", os.getenv("VLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
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", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
speech_tts_model=_str_env("RISK_SPEECH_TTS_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"),
|
||||
speech_asr_model=_str_env("RISK_SPEECH_ASR_MODEL", "Qwen3-ASR-0.6B-8bit"),
|
||||
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),
|
||||
speech_tts_max_input_chars=_int_env("RISK_SPEECH_TTS_MAX_INPUT_CHARS", 4096),
|
||||
speech_asr_max_input_bytes=_int_env("RISK_SPEECH_ASR_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),
|
||||
speech_tts_input_cost_per_1k_chars=_float_env("RISK_SPEECH_TTS_INPUT_COST_PER_1K_CHARS", 0.0),
|
||||
speech_tts_output_cost_per_minute_audio=_float_env("RISK_SPEECH_TTS_OUTPUT_COST_PER_MINUTE_AUDIO", 0.0),
|
||||
speech_asr_input_cost_per_mb=_float_env("RISK_SPEECH_ASR_INPUT_COST_PER_MB", 0.0),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
# Backend Tests 测试指引
|
||||
|
||||
本文件适用于 backend/tests/ 下的测试和测试脚本。
|
||||
|
||||
## 测试入口
|
||||
|
||||
- pytest.ini 指定默认测试目录为 backend/tests,并设置后端覆盖率门槛为 90%。
|
||||
- run_tests.py 提供 unit、integration、simulate、all 几种快捷入口。
|
||||
- 默认优先使用 pytest 跑窄测试;只有在需要脚本封装参数时再用 run_tests.py。
|
||||
|
||||
## 测试分布
|
||||
|
||||
- test_main_endpoints.py:主 API 路由行为
|
||||
- test_main_cancel.py:补全取消和任务生命周期
|
||||
- test_prompt.py、test_prompt_extended.py:Prompt 上下文与规则
|
||||
- test_llm.py、test_llm_extended.py:LLM 包装层
|
||||
- test_geoip.py:GeoIP 逻辑
|
||||
- test_tts_asr_*.py:TTS 相关与历史 TTS/ASR 面
|
||||
- simulate_macos.py:历史模拟脚本
|
||||
- quick_verify.py、verify_cross.py、play_audio.py:人工验证或辅助脚本
|
||||
|
||||
## 常用命令
|
||||
|
||||
- pytest
|
||||
- pytest backend/tests/test_main_endpoints.py -v
|
||||
- pytest backend/tests/test_main_cancel.py -v
|
||||
- pytest backend/tests/test_prompt.py -v
|
||||
- pytest backend/tests/test_llm.py -v
|
||||
- python backend/tests/run_tests.py unit
|
||||
- python backend/tests/run_tests.py integration --url http://localhost:8001 --key your-secret-key-here
|
||||
|
||||
## 测试原则
|
||||
|
||||
- 优先跑与改动直接对应的窄测试,不要动不动全量跑。
|
||||
- 单元测试尽量 mock 掉外部依赖,不要直连真实 Ollama。
|
||||
- 涉及 main.py 时,优先用 monkeypatch 或 fake 对象替代:
|
||||
- call_ollama
|
||||
- call_vlm_ocr
|
||||
- MarkItDown
|
||||
- GeoIP 查询
|
||||
- TTS 模型加载
|
||||
- 测试要保持确定性,不依赖全局状态、环境顺序或人工输入。
|
||||
|
||||
## 容易误判的点
|
||||
|
||||
- 覆盖率门槛是针对多个 backend 模块一起算的,改核心文件时,即使单测通过也可能因为覆盖率不够失败。
|
||||
- htmlcov、.pytest_cache、api_performance_report.md 属于生成产物,不是需要维护的源码。
|
||||
- 这一目录里有一批 TTS/ASR 测试和说明明显继承自旧实现;当它们与当前 backend/tts_asr.py 冲突时,不要默认代码错了,先确认目标产品面。
|
||||
- integration 脚本通常假设本地服务在 http://localhost:8001,且默认 API Key 还是占位值。
|
||||
|
||||
## 改动定位建议
|
||||
|
||||
- 路由返回值不对:先看 test_main_endpoints.py 和 test_main_cancel.py
|
||||
- Prompt 规则不对:先看 test_prompt.py 和 test_prompt_extended.py
|
||||
- Ollama 调用包装不对:先看 test_llm.py 和 test_llm_extended.py
|
||||
- TTS 面变化:先确认当前 backend/tts_asr.py 是不是仍然以旧文档描述为目标,再决定修测试还是修实现
|
||||
|
||||
## 维护原则
|
||||
|
||||
- 新增后端行为时,优先给对应模块补测试,不要只依赖全量回归。
|
||||
- 如果变更的是历史 TTS/ASR 面,先把“当前规范是什么”确定下来,再批量修测试。
|
||||
- 如果覆盖率策略变化,记得同步这个文件,而不是只改 pytest.ini。
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Lightweight benchmark for TTS/ASR queueing and API throughput.
|
||||
|
||||
This benchmark uses the FastAPI app with a mocked upstream speech API so it
|
||||
measures this project's queueing, request handling, and SSE delivery cost
|
||||
without requiring a real external model endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import main # noqa: E402
|
||||
import tts_asr # noqa: E402
|
||||
from job_system import reset_job_manager # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes(duration_ms: int = 320) -> bytes:
|
||||
sample_rate = 16000
|
||||
frames = max(1, int(sample_rate * duration_ms / 1000))
|
||||
data = b"".join((i % 32768).to_bytes(2, "little", signed=False) for i in range(frames))
|
||||
data_size = len(data)
|
||||
return (
|
||||
b"RIFF" + (36 + data_size).to_bytes(4, "little")
|
||||
+ b"WAVE"
|
||||
+ b"fmt " + (16).to_bytes(4, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ (2).to_bytes(2, "little")
|
||||
+ (16).to_bytes(2, "little")
|
||||
+ b"data" + data_size.to_bytes(4, "little")
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
def _parse_sse_done(text: str) -> dict:
|
||||
for chunk in reversed([item for item in text.split("\n\n") if item.strip()]):
|
||||
event = ""
|
||||
data = ""
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("event:"):
|
||||
event = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("data:"):
|
||||
data = line.split(":", 1)[1].strip()
|
||||
if event == "done" and data:
|
||||
payload = json.loads(data)
|
||||
result = dict(payload.get("result") or {})
|
||||
for key in ("queue_ms", "run_ms", "total_ms", "queued_count", "running_count", "busy_level", "busy_ratio"):
|
||||
if key in payload:
|
||||
result[key] = payload[key]
|
||||
return result
|
||||
raise RuntimeError("done event not found")
|
||||
|
||||
|
||||
def _percentile(values: list[float], q: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
index = (len(values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(values) - 1)
|
||||
if lower == upper:
|
||||
return values[lower]
|
||||
weight = index - lower
|
||||
return values[lower] * (1 - weight) + values[upper] * weight
|
||||
|
||||
|
||||
async def _build_mock_client(tts_delay_ms: int, asr_delay_ms: int) -> httpx.AsyncClient:
|
||||
async def transport(request: httpx.Request):
|
||||
if request.url.path.endswith("/audio/speech"):
|
||||
await asyncio.sleep(tts_delay_ms / 1000.0)
|
||||
return httpx.Response(200, content=_wav_bytes(420), headers={"x-request-id": "bench-tts"}, request=request)
|
||||
await asyncio.sleep(asr_delay_ms / 1000.0)
|
||||
return httpx.Response(200, json={"text": "benchmark transcript", "language": "zh"}, headers={"x-request-id": "bench-asr"}, request=request)
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url="https://benchmark.example/v1/",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
|
||||
|
||||
async def _run_case(case_name: str, concurrency: int, request_count: int, audio_b64: str | None = None) -> dict:
|
||||
results: list[dict] = []
|
||||
latencies: list[float] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=main.app),
|
||||
base_url="http://testserver",
|
||||
timeout=120.0,
|
||||
) as client:
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def fire(index: int) -> None:
|
||||
async with semaphore:
|
||||
started = time.perf_counter()
|
||||
if case_name == "tts":
|
||||
response = await client.post(
|
||||
"/v1/tts-asr/tts",
|
||||
json={"text": f"第 {index} 条基准文本", "speaker": "Vivian", "format": "wav"},
|
||||
)
|
||||
else:
|
||||
response = await client.post(
|
||||
"/v1/tts-asr/asr",
|
||||
json={"audio_base64": audio_b64, "language": "zh-CN"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = _parse_sse_done(response.text)
|
||||
latencies.append((time.perf_counter() - started) * 1000.0)
|
||||
results.append(payload)
|
||||
|
||||
await asyncio.gather(*(fire(index) for index in range(request_count)))
|
||||
|
||||
queue_values = sorted(float(item.get("queue_ms", 0) or 0) for item in results)
|
||||
run_values = sorted(float(item.get("run_ms", 0) or 0) for item in results)
|
||||
total_values = sorted(float(item.get("total_ms", 0) or 0) for item in results)
|
||||
latency_values = sorted(latencies)
|
||||
elapsed_sum_ms = sum(latency_values)
|
||||
return {
|
||||
"case": case_name,
|
||||
"requests": request_count,
|
||||
"concurrency": concurrency,
|
||||
"avg_latency_ms": round(statistics.fmean(latency_values), 2),
|
||||
"p95_latency_ms": round(_percentile(latency_values, 0.95), 2),
|
||||
"avg_queue_ms": round(statistics.fmean(queue_values), 2),
|
||||
"p95_queue_ms": round(_percentile(queue_values, 0.95), 2),
|
||||
"avg_run_ms": round(statistics.fmean(run_values), 2),
|
||||
"p95_run_ms": round(_percentile(run_values, 0.95), 2),
|
||||
"avg_total_ms": round(statistics.fmean(total_values), 2),
|
||||
"p95_total_ms": round(_percentile(total_values, 0.95), 2),
|
||||
"throughput_rps_estimate": round((request_count * 1000.0) / max(latency_values[-1], elapsed_sum_ms / max(request_count, 1)), 2),
|
||||
}
|
||||
|
||||
|
||||
async def main_async(args) -> None:
|
||||
os.environ["JOB_BACKEND"] = "memory"
|
||||
os.environ["JOB_TTS_CONCURRENCY"] = str(args.tts_workers)
|
||||
os.environ["JOB_TTS_MAX_QUEUE"] = str(max(args.tts_requests, args.tts_workers))
|
||||
os.environ["JOB_ASR_CONCURRENCY"] = str(args.asr_workers)
|
||||
os.environ["JOB_ASR_MAX_QUEUE"] = str(max(args.asr_requests, args.asr_workers))
|
||||
reset_job_manager()
|
||||
|
||||
mock_client = await _build_mock_client(args.tts_delay_ms, args.asr_delay_ms)
|
||||
tts_asr._httpx_client = mock_client
|
||||
try:
|
||||
audio_b64 = base64.b64encode(_wav_bytes(args.audio_duration_ms)).decode("utf-8")
|
||||
tts_stats = await _run_case("tts", args.tts_concurrency, args.tts_requests)
|
||||
asr_stats = await _run_case("asr", args.asr_concurrency, args.asr_requests, audio_b64=audio_b64)
|
||||
finally:
|
||||
await mock_client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
reset_job_manager()
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"benchmark_date": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"assumptions": {
|
||||
"upstream_tts_delay_ms": args.tts_delay_ms,
|
||||
"upstream_asr_delay_ms": args.asr_delay_ms,
|
||||
"job_backend": "memory",
|
||||
},
|
||||
"tts": tts_stats,
|
||||
"asr": asr_stats,
|
||||
"recommended_defaults": {
|
||||
"JOB_TTS_CONCURRENCY": args.tts_workers,
|
||||
"JOB_TTS_MAX_QUEUE": max(16, args.tts_workers * 4),
|
||||
"JOB_ASR_CONCURRENCY": args.asr_workers,
|
||||
"JOB_ASR_MAX_QUEUE": max(8, args.asr_workers * 4),
|
||||
"TTS_ASR_MAX_CONNECTIONS": max(24, (args.tts_workers + args.asr_workers) * 4),
|
||||
"TTS_ASR_MAX_KEEPALIVE_CONNECTIONS": max(12, (args.tts_workers + args.asr_workers) * 2),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tts-delay-ms", type=int, default=120)
|
||||
parser.add_argument("--asr-delay-ms", type=int, default=280)
|
||||
parser.add_argument("--tts-workers", type=int, default=4)
|
||||
parser.add_argument("--asr-workers", type=int, default=2)
|
||||
parser.add_argument("--tts-concurrency", type=int, default=8)
|
||||
parser.add_argument("--asr-concurrency", type=int, default=4)
|
||||
parser.add_argument("--tts-requests", type=int, default=32)
|
||||
parser.add_argument("--asr-requests", type=int, default=16)
|
||||
parser.add_argument("--audio-duration-ms", type=int, default=320)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main_async(parse_args()))
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Speech test runner for the current API-based TTS/ASR stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_command(cmd: list[str], cwd: str | None = None) -> int:
|
||||
print(f"\n执行: {' '.join(cmd)}")
|
||||
print("-" * 70)
|
||||
return subprocess.run(cmd, cwd=cwd).returncode
|
||||
|
||||
|
||||
def run_unit_tests(verbose: bool = False) -> int:
|
||||
cmd = ["pytest", "backend/tests/test_tts_asr.py"]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_benchmark(extra_args: list[str] | None = None) -> int:
|
||||
cmd = ["python", "backend/tests/benchmark_tts_asr.py"]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_all(verbose: bool = False) -> int:
|
||||
results = [
|
||||
("单元测试", run_unit_tests(verbose=verbose)),
|
||||
("基准测试", run_benchmark()),
|
||||
]
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("测试结果汇总")
|
||||
print("=" * 70)
|
||||
passed = 0
|
||||
for name, code in results:
|
||||
ok = code == 0
|
||||
passed += int(ok)
|
||||
print(f"{name}: {'✓ 通过' if ok else '✗ 失败'}")
|
||||
print("-" * 70)
|
||||
print(f"总计: {passed}/{len(results)} 通过")
|
||||
return 0 if passed == len(results) else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="当前 API 化 TTS/ASR 测试运行器")
|
||||
subparsers = parser.add_subparsers(dest="command", help="测试类型")
|
||||
|
||||
unit_parser = subparsers.add_parser("unit", help="运行当前 TTS/ASR 单元测试")
|
||||
unit_parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
|
||||
benchmark_parser = subparsers.add_parser("benchmark", help="运行当前 TTS/ASR benchmark")
|
||||
benchmark_parser.add_argument("benchmark_args", nargs="*", help="透传给 benchmark_tts_asr.py")
|
||||
|
||||
all_parser = subparsers.add_parser("all", help="运行当前 TTS/ASR 单元测试和 benchmark")
|
||||
all_parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
|
||||
args = parser.parse_args()
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
if args.command == "unit":
|
||||
return run_unit_tests(verbose=args.verbose)
|
||||
if args.command == "benchmark":
|
||||
return run_benchmark(extra_args=args.benchmark_args)
|
||||
if args.command == "all":
|
||||
return run_all(verbose=args.verbose)
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regression tests for PostgreSQL audit persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import audit_store # noqa: E402
|
||||
from audit_store import PostgresAuditStore # noqa: E402
|
||||
|
||||
|
||||
class _RecordingCursor:
|
||||
def __init__(self) -> None:
|
||||
self.query = ""
|
||||
self.params = ()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, query: str, params=()) -> None:
|
||||
self.query = query
|
||||
self.params = params or ()
|
||||
assert query.count("%s") == len(self.params)
|
||||
|
||||
|
||||
class _RecordingConnection:
|
||||
def __init__(self, cursor: _RecordingCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self) -> _RecordingCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
def test_record_llm_call_keeps_columns_placeholders_and_params_aligned(monkeypatch):
|
||||
cursor = _RecordingCursor()
|
||||
monkeypatch.setattr(audit_store, "psycopg", object())
|
||||
store = PostgresAuditStore("postgresql://unused")
|
||||
store._initialized = True
|
||||
monkeypatch.setattr(store, "_connect", lambda: _RecordingConnection(cursor))
|
||||
|
||||
store.record_llm_call({
|
||||
"request_id": "request-1",
|
||||
"session_hash": "session",
|
||||
"ip_hash": "ip",
|
||||
"job_type": "ocr",
|
||||
"model": "vision-model",
|
||||
"estimated_input_tokens": 12,
|
||||
"max_output_tokens": 256,
|
||||
"estimated_cost": 0.01,
|
||||
"actual_output_chars": 42,
|
||||
"actual_cost": 0.02,
|
||||
"queue_ms": 10,
|
||||
"run_ms": 20,
|
||||
"total_ms": 30,
|
||||
"status": "completed",
|
||||
"error_code": "",
|
||||
"metadata": {"source": "test"},
|
||||
})
|
||||
|
||||
assert "INSERT INTO llm_call_audit" in cursor.query
|
||||
assert cursor.query.count("%s") == 16
|
||||
assert len(cursor.params) == 16
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,167 @@
|
||||
import sys
|
||||
import types
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
# Ensure the backend directory is on sys.path so we can import the geoip module directly
|
||||
BACKEND_DIR = pathlib.Path(__file__).resolve().parents[1] # backend/ folder
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import geoip as geoip
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_geoip_reader():
|
||||
# Ensure each test starts with a clean cache
|
||||
geoip._geoip_reader = None
|
||||
yield
|
||||
geoip._geoip_reader = None
|
||||
|
||||
|
||||
def test_get_reader_import_error(monkeypatch):
|
||||
import builtins
|
||||
real_import = getattr(builtins, "__import__")
|
||||
|
||||
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "geoip2.database":
|
||||
raise ImportError("simulate missing geoip2")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
assert geoip._get_reader() is None
|
||||
|
||||
|
||||
def test_get_reader_db_missing(monkeypatch):
|
||||
# Provide a fake geoip2 module, but force the database file to be considered missing
|
||||
fake_db_module = types.ModuleType("geoip2.database")
|
||||
class FakeReader:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
fake_db_module.Reader = FakeReader
|
||||
|
||||
fake_geoip2 = types.ModuleType("geoip2")
|
||||
fake_geoip2.database = fake_db_module
|
||||
|
||||
sys.modules["geoip2"] = fake_geoip2
|
||||
sys.modules["geoip2.database"] = fake_db_module
|
||||
|
||||
# Ensure path existence check returns False
|
||||
monkeypatch.setattr(geoip.os.path, "exists", lambda p: False)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
assert geoip._get_reader() is None
|
||||
|
||||
# Clean up injected modules
|
||||
del sys.modules["geoip2"]
|
||||
del sys.modules["geoip2.database"]
|
||||
|
||||
|
||||
def test_get_reader_loads_and_caches(monkeypatch):
|
||||
fake_db_module = types.ModuleType("geoip2.database")
|
||||
class FakeReader:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
fake_db_module.Reader = FakeReader
|
||||
|
||||
fake_geoip2 = types.ModuleType("geoip2")
|
||||
fake_geoip2.database = fake_db_module
|
||||
|
||||
sys.modules["geoip2"] = fake_geoip2
|
||||
sys.modules["geoip2.database"] = fake_db_module
|
||||
|
||||
# Simulate that the database file exists
|
||||
monkeypatch.setattr(geoip.os.path, "exists", lambda p: True)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
r1 = geoip._get_reader()
|
||||
assert isinstance(r1, FakeReader)
|
||||
# Second call should return the same cached instance
|
||||
r2 = geoip._get_reader()
|
||||
assert r1 is r2
|
||||
# Clean up injected modules
|
||||
del sys.modules["geoip2"]
|
||||
del sys.modules["geoip2.database"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", [None, "", "127.0.0.1", "localhost", "::1"])
|
||||
def test_get_ip_location_none_inputs(ip):
|
||||
assert geoip.get_ip_location(ip) is None
|
||||
|
||||
|
||||
def test_get_ip_location_reader_none(monkeypatch):
|
||||
# When there is no reader (no database), return None
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: None)
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_successful_lookup(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
country = SimpleNamespace(name="United States")
|
||||
region = SimpleNamespace(name="California")
|
||||
resp = SimpleNamespace(
|
||||
country=country,
|
||||
subdivisions=SimpleNamespace(most_specific=region),
|
||||
city=SimpleNamespace(name="Mountain View"),
|
||||
)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
loc = geoip.get_ip_location("1.2.3.4")
|
||||
assert loc == {
|
||||
"country": "United States",
|
||||
"region": "California",
|
||||
"city": "Mountain View",
|
||||
"display": "United States California Mountain View",
|
||||
}
|
||||
|
||||
|
||||
def test_get_ip_location_reader_exception(monkeypatch):
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
raise Exception("boom")
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_no_location_parts(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
resp = SimpleNamespace(country=SimpleNamespace(name=None), subdivisions=None, city=None)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_text_valid(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
country = SimpleNamespace(name="United States")
|
||||
region = SimpleNamespace(name="California")
|
||||
resp = SimpleNamespace(
|
||||
country=country,
|
||||
subdivisions=SimpleNamespace(most_specific=region),
|
||||
city=SimpleNamespace(name="Mountain View"),
|
||||
)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location_text("1.2.3.4") == "United States California Mountain View"
|
||||
|
||||
|
||||
def test_get_ip_location_text_none_when_no_location(monkeypatch):
|
||||
# Force get_ip_location to return None
|
||||
monkeypatch.setattr(geoip, "get_ip_location", lambda ip: None)
|
||||
assert geoip.get_ip_location_text("1.2.3.4") == ""
|
||||
@@ -0,0 +1,302 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
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_openai_format():
|
||||
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_openai_reasoning_content():
|
||||
resp = {"choices": [{"message": {"content": "answer", "reasoning_content": "deep thought"}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "answer"
|
||||
assert thinking == "deep thought"
|
||||
|
||||
|
||||
def test_extract_message_empty_choices():
|
||||
resp = {"choices": []}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_no_choices_key():
|
||||
resp = {}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_none_content():
|
||||
resp = {"choices": [{"message": {"content": None, "thinking": None}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_delta_text():
|
||||
chunk = {"choices": [{"delta": {"content": "hello"}}]}
|
||||
assert llm._extract_delta_text(chunk) == "hello"
|
||||
|
||||
|
||||
def test_extract_delta_text_empty():
|
||||
chunk = {"choices": [{"delta": {}}]}
|
||||
assert llm._extract_delta_text(chunk) == ""
|
||||
|
||||
|
||||
def test_extract_delta_thinking():
|
||||
chunk = {"choices": [{"delta": {"thinking": "reasoning step"}}]}
|
||||
assert llm._extract_delta_thinking(chunk) == "reasoning step"
|
||||
|
||||
|
||||
def test_extract_delta_reasoning_content():
|
||||
chunk = {"choices": [{"delta": {"reasoning_content": "deep thought"}}]}
|
||||
assert llm._extract_delta_thinking(chunk) == "deep thought"
|
||||
|
||||
|
||||
def test_resolve_model_name_explicit():
|
||||
assert llm._resolve_model_name("custom-model") == "custom-model"
|
||||
|
||||
|
||||
def test_resolve_model_name_default():
|
||||
assert llm._resolve_model_name() == llm.LLM_MODEL
|
||||
|
||||
|
||||
def test_resolve_model_name_pro():
|
||||
assert llm._resolve_model_name(use_pro_model=True) == llm.PRO_LLM_MODEL
|
||||
|
||||
|
||||
def test_resolve_system_prompt():
|
||||
assert llm._resolve_system_prompt(" system prompt ") == "system prompt"
|
||||
assert llm._resolve_system_prompt("") == ""
|
||||
assert llm._resolve_system_prompt(None) == ""
|
||||
|
||||
|
||||
def test_build_chat_payload_with_system():
|
||||
payload = llm._build_chat_payload(
|
||||
"user prompt", system_prompt="sys prompt", temperature=0.5, model="test-model"
|
||||
)
|
||||
assert payload["model"] == "test-model"
|
||||
assert len(payload["messages"]) == 2
|
||||
assert payload["messages"][0]["role"] == "system"
|
||||
assert payload["messages"][1]["role"] == "user"
|
||||
assert payload["stream"] is False
|
||||
|
||||
|
||||
def test_build_chat_payload_no_system():
|
||||
payload = llm._build_chat_payload("user prompt", system_prompt=None)
|
||||
assert len(payload["messages"]) == 1
|
||||
assert payload["stream"] is False
|
||||
|
||||
|
||||
def test_build_chat_payload_with_thinking():
|
||||
payload = llm._build_chat_payload("prompt", thinking="low")
|
||||
assert "options" in payload
|
||||
assert payload["options"]["think"] == "low"
|
||||
|
||||
|
||||
def test_build_chat_stream_payload():
|
||||
payload = llm._build_chat_stream_payload("prompt", system_prompt="sys")
|
||||
assert payload["stream"] is True
|
||||
assert len(payload["messages"]) == 2
|
||||
|
||||
|
||||
def test_build_chat_stream_payload_with_thinking():
|
||||
payload = llm._build_chat_stream_payload("prompt", thinking="high")
|
||||
assert "options" in payload
|
||||
assert payload["options"]["think"] == "high"
|
||||
|
||||
|
||||
def test_call_ollama_non_streaming(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
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
|
||||
def json(self): return {"choices": [{"message": {"content": "done"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
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("test prompt", system_prompt="sys", tag="t1")
|
||||
)
|
||||
|
||||
assert result["content"] == "done"
|
||||
assert captured["url"] == "/chat/completions"
|
||||
assert captured["json"]["stream"] is False
|
||||
|
||||
|
||||
def test_stream_ollama_text_deltas(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"choices": [{"delta": {"content": "hel"}}]}',
|
||||
'data: {"choices": [{"delta": {"content": "lo"}}]}',
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
class LineIterator:
|
||||
def __aiter__(self2): return self2
|
||||
async def __anext__(self2):
|
||||
try:
|
||||
return next(lines_iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration()
|
||||
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
def raise_for_status(self2): pass
|
||||
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()
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
return Client()
|
||||
|
||||
def fake_client(*args, **kwargs):
|
||||
captured["called"] = True
|
||||
return make_lines()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
results = []
|
||||
async def collect():
|
||||
async for delta in llm.stream_ollama("prompt", tag="t1"):
|
||||
results.append(delta)
|
||||
|
||||
asyncio.run(collect())
|
||||
assert captured.get("called") is True
|
||||
assert results == ["hel", "lo"]
|
||||
|
||||
|
||||
def test_stream_ollama_events_thinking_and_content(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"choices": [{"delta": {"thinking": "reasoning"}}]}',
|
||||
'data: {"choices": [{"delta": {"content": "answer"}}]}',
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
class LineIterator:
|
||||
def __aiter__(self2): return self2
|
||||
async def __anext__(self2):
|
||||
try:
|
||||
return next(lines_iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration()
|
||||
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
def raise_for_status(self2): pass
|
||||
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()
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
return Client()
|
||||
|
||||
def fake_client(*args, **kwargs):
|
||||
captured["called"] = True
|
||||
return make_lines()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
results = []
|
||||
async def collect():
|
||||
async for event_type, payload in llm.stream_ollama_events("prompt", tag="t1"):
|
||||
results.append((event_type, payload))
|
||||
|
||||
asyncio.run(collect())
|
||||
assert captured.get("called") is True
|
||||
# First event should be thinking, then content
|
||||
assert results[0] == ("thinking", "")
|
||||
assert results[1][0] == "content"
|
||||
|
||||
|
||||
def test_call_vlm_ocr(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
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
|
||||
def json(self): return {"choices": [{"message": {"content": "ocr text"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
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"fake image bytes"))
|
||||
assert result == "ocr text"
|
||||
|
||||
# Verify the payload uses OpenAI vision format (image_url)
|
||||
assert captured["url"] == "/chat/completions"
|
||||
messages = captured["json"]["messages"]
|
||||
assert len(messages) == 1
|
||||
content_parts = messages[0]["content"]
|
||||
# Should have text part and image_url part
|
||||
assert any(p.get("type") == "text" for p in content_parts)
|
||||
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
|
||||
@@ -0,0 +1,167 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
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))
|
||||
|
||||
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",
|
||||
"suffix": "",
|
||||
"languageId": "markdown",
|
||||
"model_thinking": "low",
|
||||
"privacy_mode": True,
|
||||
}
|
||||
|
||||
|
||||
def test_cancel_endpoint_cancels_running_task(monkeypatch):
|
||||
started = threading.Event()
|
||||
cancelled = threading.Event()
|
||||
|
||||
async def fake_call_ollama(*args, **kwargs):
|
||||
started.set()
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(0.05)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
monkeypatch.setattr(job_handlers, "call_ollama", fake_call_ollama)
|
||||
request_id = "req-cancel-1"
|
||||
|
||||
with TestClient(main.app) as client:
|
||||
response_box = {}
|
||||
|
||||
def send_completion():
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/completions",
|
||||
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()
|
||||
|
||||
assert started.wait(timeout=2.0)
|
||||
|
||||
cancel_response = client.post(
|
||||
"/v1/completions/cancel",
|
||||
headers=API_KEY_HEADERS,
|
||||
json={"request_id": request_id, "reason": "superseded"},
|
||||
)
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
|
||||
|
||||
completion_thread.join(timeout=5.0)
|
||||
assert not completion_thread.is_alive()
|
||||
assert cancelled.wait(timeout=2.0)
|
||||
assert "event: cancelled" in response_box["body"]
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.acks = []
|
||||
|
||||
async def xack(self, *args):
|
||||
self.acks.append(args)
|
||||
|
||||
async def hincrby(self, key, field, amount):
|
||||
return 0
|
||||
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self):
|
||||
self.redis = FakeRedis()
|
||||
self.statuses = {}
|
||||
|
||||
async def get_status(self, job_id):
|
||||
return self.statuses.get(job_id)
|
||||
|
||||
async def _set_state(self, job_id, state):
|
||||
self.statuses[job_id] = state
|
||||
|
||||
async def _metrics(self, job_type):
|
||||
return {"queued_count": 0, "running_count": 0}
|
||||
|
||||
async def _emit_event(self, job_id, event, data):
|
||||
self.statuses[job_id]["event"] = event
|
||||
|
||||
def _metrics_key(self, job_type):
|
||||
return f"metrics:{job_type}"
|
||||
|
||||
def _state_key(self, job_id):
|
||||
return f"state:{job_id}"
|
||||
|
||||
|
||||
async def _run_cancelled_after_handler(manager, job_type):
|
||||
worker = job_system.RedisWorker(manager)
|
||||
await worker._run_message(
|
||||
job_type,
|
||||
"queue",
|
||||
"group",
|
||||
"msg-1",
|
||||
{"job_id": "job-1"},
|
||||
asyncio.Semaphore(1),
|
||||
)
|
||||
|
||||
|
||||
def test_redis_worker_acks_when_handler_returns_cancelled_state():
|
||||
async def handler(payload, emit, is_cancelled):
|
||||
return {"ok": True}
|
||||
|
||||
async def coro():
|
||||
manager = FakeManager()
|
||||
manager.handlers = {"completion": handler}
|
||||
manager.statuses["job-1"] = {
|
||||
"request_id": "req-1",
|
||||
"type": "completion",
|
||||
"status": "running",
|
||||
"created_at": 1,
|
||||
}
|
||||
await _run_cancelled_after_handler(manager, "completion")
|
||||
assert manager.redis.acks == [("queue", "group", "msg-1")]
|
||||
|
||||
asyncio.run(coro())
|
||||
|
||||
|
||||
def test_cancel_not_found():
|
||||
with TestClient(main.app) as client:
|
||||
response = client.post(
|
||||
"/v1/completions/cancel",
|
||||
headers=API_KEY_HEADERS,
|
||||
json={"request_id": "missing", "reason": "abort"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"cancelled": False, "status": "not_found"}
|
||||
@@ -0,0 +1,286 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 docs_store # 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()
|
||||
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:
|
||||
def __init__(self, host=None, headers=None):
|
||||
class Client:
|
||||
pass
|
||||
self.client = Client() if host is not None else None
|
||||
if self.client is not None:
|
||||
self.client.host = host
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
def test_preview_short_text():
|
||||
assert main._preview("Hello") == "Hello"
|
||||
|
||||
|
||||
def test_preview_long_text_truncated():
|
||||
long_text = "a" * 100
|
||||
assert main._preview(long_text) == long_text[:80] + "..."
|
||||
|
||||
|
||||
def test_sanitize_markdown_strips_image_markdown():
|
||||
assert "" not in main._sanitize_converted_markdown("text ")
|
||||
|
||||
|
||||
def test_sanitize_inline_completion_strips_prefill():
|
||||
assert main.sanitize_inline_completion_content("系统非常适合写作", prefill="系统") == "非常适合写作"
|
||||
|
||||
|
||||
def test_get_client_ip_header_overrides_host():
|
||||
req = DummyRequest(host="1.2.3.4", headers={"X-Client-IP": "5.6.7.8"})
|
||||
assert main.get_client_ip(req) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_post_completions_without_api_key_uses_anonymous_session(monkeypatch):
|
||||
async def fake_call(*args, **kwargs):
|
||||
return {"content": "系统done", "think": ""}
|
||||
|
||||
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
|
||||
assert main.config.session_cookie_name in resp.cookies
|
||||
|
||||
|
||||
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_completions_returns_sse_done(monkeypatch):
|
||||
async def fake_call(*args, **kwargs):
|
||||
return {"content": "系统done", "think": ""}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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": "medium", "privacy_mode": True,
|
||||
}) as resp:
|
||||
assert resp.status_code == 200
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert ": keepalive" in body
|
||||
assert "event: done" in body
|
||||
|
||||
|
||||
def test_post_ocr_mocked(monkeypatch):
|
||||
async def fake_ocr(*args, **kwargs):
|
||||
return "OCR result text"
|
||||
|
||||
monkeypatch.setattr(job_handlers, "call_vlm_ocr", fake_ocr)
|
||||
img_b64 = base64.b64encode(b"pretend image data").decode()
|
||||
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
|
||||
body = "".join(resp.iter_text())
|
||||
assert "OCR result text" in body
|
||||
|
||||
|
||||
def test_post_video_ocr_merges_ocr_and_asr(monkeypatch):
|
||||
async def fake_ocr(*args, **kwargs):
|
||||
return "画面文字"
|
||||
|
||||
async def fake_asr(*args, **kwargs):
|
||||
return SimpleNamespace(text="音频转写")
|
||||
|
||||
monkeypatch.setattr(job_handlers, "call_vlm_ocr", fake_ocr)
|
||||
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
|
||||
monkeypatch.setattr(job_handlers, "extract_audio_wav_bytes", lambda _path: b"fake wav")
|
||||
|
||||
video_b64 = base64.b64encode(b"pretend video data").decode()
|
||||
with TestClient(main.app) as client:
|
||||
with client.stream("POST", "/v1/ocr", headers=HEADERS, json={
|
||||
"image": video_b64,
|
||||
"filename": "sample.mp4",
|
||||
"language": "auto",
|
||||
"media_type": "video",
|
||||
"mime_type": "video/mp4",
|
||||
}) as resp:
|
||||
assert resp.status_code == 200
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "视频画面 OCR" in body
|
||||
assert "视频音频 ASR" in body
|
||||
assert "画面文字" in body
|
||||
assert "音频转写" in body
|
||||
|
||||
|
||||
def test_post_convert_txt_returns_markdown():
|
||||
content = base64.b64encode(b"hello world").decode()
|
||||
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
|
||||
body = "".join(resp.iter_text())
|
||||
assert "hello world" in body
|
||||
|
||||
|
||||
def test_post_convert_unsupported_extension_returns_500():
|
||||
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",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
assert "仅支持" in resp.json()["error"]
|
||||
|
||||
|
||||
def test_post_convert_rejects_mismatched_content_suffix():
|
||||
content = base64.b64encode(b"%PDF-1.4\n%%EOF").decode()
|
||||
with TestClient(main.app) as client:
|
||||
resp = client.post("/v1/convert", headers=HEADERS, json={
|
||||
"file": content, "filename": "sample.txt",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
assert "仅支持" in resp.json()["error"]
|
||||
|
||||
|
||||
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 folder_resp.status_code == 200
|
||||
folder = folder_resp.json()["node"]
|
||||
|
||||
file_resp = client.post("/v1/docs/files/text", headers=HEADERS, json={
|
||||
"name": "notes.md",
|
||||
"parentId": folder["id"],
|
||||
"content": "# hello",
|
||||
})
|
||||
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
|
||||
|
||||
rename_resp = client.patch(f"/v1/docs/nodes/{file_node['id']}", headers=HEADERS, json={
|
||||
"name": "renamed.md",
|
||||
})
|
||||
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_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"
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,110 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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))
|
||||
|
||||
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",
|
||||
"suffix": "After",
|
||||
"languageId": "markdown",
|
||||
"instruction": "expand",
|
||||
"pro_thinking": "medium",
|
||||
"privacy_mode": True,
|
||||
"user_preferences": {
|
||||
"language": "zh",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_pro_queue_full_returns_429(monkeypatch):
|
||||
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
|
||||
|
||||
|
||||
def test_pro_status_missing_returns_404():
|
||||
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_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] model for llm-in-text" in combined
|
||||
assert "pro_mode: true" in combined
|
||||
assert "pro_thinking_level: high" in combined
|
||||
|
||||
|
||||
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 "Preferred language: zh" in user_prompt
|
||||
assert "Preferred country: CN" in user_prompt
|
||||
assert "Preferred timezone: Asia/Shanghai" in user_prompt
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,118 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import prompt # noqa: E402
|
||||
|
||||
|
||||
def test_prompt_builds_system_and_user():
|
||||
system_prompt, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="The result is ",
|
||||
suffix="for this dataset.",
|
||||
language_id="markdown",
|
||||
)
|
||||
|
||||
assert "inline completion engine" in system_prompt
|
||||
assert "$...$" in system_prompt
|
||||
assert "$$...$$" in system_prompt
|
||||
assert "```{language}" in system_prompt
|
||||
assert "Mermaid" in system_prompt
|
||||
assert "CURSOR_FENCE_LANGUAGE" in system_prompt
|
||||
assert "MERMAID_CONTEXT" in system_prompt
|
||||
assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt
|
||||
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
|
||||
assert "MERMAID_CONTEXT" in user_prompt
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
|
||||
assert "actual line breaks" in system_prompt
|
||||
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
|
||||
assert "Do not explain newline or boundary choices" in user_prompt
|
||||
assert "Continue after the PREFILL text" in user_prompt
|
||||
assert "Step 1" not in user_prompt
|
||||
assert "Does output need" not in user_prompt
|
||||
assert "assistant" in system_prompt
|
||||
assert "fim_middle" in system_prompt
|
||||
assert prefill == ""
|
||||
assert "start output with \\n" not in user_prompt
|
||||
assert "Use single \\n" not in system_prompt
|
||||
|
||||
|
||||
def test_completion_prefill_appended_to_fim_middle():
|
||||
_, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="即时可用的 LLM 系统",
|
||||
suffix="",
|
||||
)
|
||||
assert prefill == "系统"
|
||||
assert user_prompt.endswith("<|fim_middle|>系统")
|
||||
|
||||
|
||||
def test_completion_prefill_empty_after_newline():
|
||||
_, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="即时可用的 LLM 系统\n",
|
||||
suffix="",
|
||||
)
|
||||
assert prefill == ""
|
||||
assert user_prompt.endswith("<|fim_middle|>")
|
||||
|
||||
|
||||
def test_cursor_in_fence_detection():
|
||||
assert prompt._cursor_in_fenced_code_block("") is False
|
||||
assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True
|
||||
assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n```\n") is False
|
||||
assert prompt._cursor_in_fenced_code_block("text ```not-a-fence``` tail") is False
|
||||
|
||||
|
||||
def test_active_fence_language_detection():
|
||||
assert prompt._active_fence_language("") == "none"
|
||||
assert prompt._active_fence_language("```mermaid\nflowchart TD\nA-->B\n") == "mermaid"
|
||||
assert prompt._active_fence_language("```python\nprint('x')\n") == "python"
|
||||
assert prompt._active_fence_language("```\nline\n") == "unknown"
|
||||
assert prompt._active_fence_language("```mermaid\nA-->B\n```\n") == "none"
|
||||
|
||||
|
||||
def test_newline_flags():
|
||||
_, user_prompt_a, _ = prompt.build_completion_prompts(
|
||||
prefix="Hello",
|
||||
suffix="World",
|
||||
)
|
||||
assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in user_prompt_a
|
||||
assert "CURSOR_FENCE_LANGUAGE: none" in user_prompt_a
|
||||
assert "MERMAID_CONTEXT: false" in user_prompt_a
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
|
||||
|
||||
_, user_prompt_b, _ = prompt.build_completion_prompts(
|
||||
prefix="Hello\n",
|
||||
suffix="\nWorld",
|
||||
)
|
||||
assert "CURSOR_FENCE_LANGUAGE: none" in user_prompt_b
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE: true" in user_prompt_b
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE: true" in user_prompt_b
|
||||
|
||||
|
||||
def test_mermaid_context_flags():
|
||||
_, prompt_in_mermaid, _ = prompt.build_completion_prompts(
|
||||
prefix="```mermaid\nflowchart TD\nA --> ",
|
||||
suffix="\n```",
|
||||
)
|
||||
assert "CURSOR_IN_FENCED_CODE_BLOCK: true" in prompt_in_mermaid
|
||||
assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid
|
||||
assert "MERMAID_CONTEXT: true" in prompt_in_mermaid
|
||||
|
||||
_, prompt_mermaid_keyword, _ = prompt.build_completion_prompts(
|
||||
prefix="Please draw a mermaid flowchart for deploy pipeline.",
|
||||
suffix="",
|
||||
)
|
||||
assert "CURSOR_IN_FENCED_CODE_BLOCK: false" in prompt_mermaid_keyword
|
||||
assert "CURSOR_FENCE_LANGUAGE: none" in prompt_mermaid_keyword
|
||||
assert "MERMAID_CONTEXT: true" in prompt_mermaid_keyword
|
||||
|
||||
|
||||
def test_examples_coverage():
|
||||
_, user_prompt, _ = prompt.build_completion_prompts(prefix="", suffix="")
|
||||
for ex in range(1, 15):
|
||||
assert f"[EX{ex:02d}]" in user_prompt
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Tests for the shared LLM speech adapter and speech job handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import job_handlers # noqa: E402
|
||||
import tts_asr # noqa: E402
|
||||
from audit_store import BaseAuditStore # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes(duration_ms: int = 100) -> bytes:
|
||||
sample_rate = 16000
|
||||
frames = max(1, int(sample_rate * duration_ms / 1000))
|
||||
data = b"".join((i % 32768).to_bytes(2, "little", signed=False) for i in range(frames))
|
||||
data_size = len(data)
|
||||
return (
|
||||
b"RIFF" + (36 + data_size).to_bytes(4, "little")
|
||||
+ b"WAVE"
|
||||
+ b"fmt " + (16).to_bytes(4, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ (2).to_bytes(2, "little")
|
||||
+ (16).to_bytes(2, "little")
|
||||
+ b"data" + data_size.to_bytes(4, "little")
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class _CaptureAuditStore(BaseAuditStore):
|
||||
def __init__(self) -> None:
|
||||
self.llm_calls: list[dict] = []
|
||||
|
||||
def record_llm_call(self, payload: dict) -> None:
|
||||
self.llm_calls.append(payload)
|
||||
|
||||
|
||||
def test_tts_calls_shared_llm_speech_endpoint(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
||||
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["url"] = str(request.url)
|
||||
captured["headers"] = dict(request.headers)
|
||||
captured["json"] = json.loads(request.read().decode("utf-8"))
|
||||
return httpx.Response(200, content=b"speech-ok", headers={"x-request-id": "tts-req-1"})
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_tts_response(
|
||||
"你好世界",
|
||||
instruct="A warm Mandarin voice.",
|
||||
speaker="Vivian",
|
||||
output_format="wav",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
result = _run_async(run())
|
||||
|
||||
assert result["format"] == "wav"
|
||||
assert result["speaker"] == "Vivian"
|
||||
assert result["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert result["upstream_request_id"] == "tts-req-1"
|
||||
assert base64.b64decode(result["audio_base64"]) == b"speech-ok"
|
||||
assert captured["url"] == "https://speech.example/v1/audio/speech"
|
||||
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
||||
payload = captured["json"]
|
||||
assert payload["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert payload["voice"] == "Vivian"
|
||||
assert payload["input"] == "你好世界"
|
||||
assert payload["instructions"] == "A warm Mandarin voice."
|
||||
assert "instruction" not in payload
|
||||
|
||||
|
||||
def test_tts_uses_nonempty_default_instructions(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["json"] = json.loads(request.read().decode("utf-8"))
|
||||
return httpx.Response(200, content=b"speech-ok")
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_tts_response("你好世界")
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
_run_async(run())
|
||||
|
||||
payload = captured["json"]
|
||||
assert payload["instructions"] == tts_asr.DEFAULT_TTS_INSTRUCTIONS
|
||||
assert payload["instructions"].strip()
|
||||
|
||||
|
||||
def test_asr_calls_shared_llm_transcriptions_endpoint(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
||||
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["url"] = str(request.url)
|
||||
captured["headers"] = dict(request.headers)
|
||||
captured["content"] = request.read()
|
||||
return httpx.Response(200, json={"text": "hello world", "language": "zh"}, headers={"x-request-id": "asr-req-1"})
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_asr_response(_wav_bytes(), language="zh-CN")
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
result = _run_async(run())
|
||||
|
||||
assert result["text"] == "hello world"
|
||||
assert result["language"] == "zh"
|
||||
assert result["model"] == "Qwen3-ASR-0.6B-8bit"
|
||||
assert result["upstream_request_id"] == "asr-req-1"
|
||||
assert captured["url"] == "https://speech.example/v1/audio/transcriptions"
|
||||
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
||||
content = captured["content"]
|
||||
assert b'name="model"' in content
|
||||
assert b"Qwen3-ASR-0.6B-8bit" in content
|
||||
assert b'name="language"' in content
|
||||
assert b"zh" in content
|
||||
|
||||
|
||||
def test_invalid_tts_text_returns_http_exception():
|
||||
with pytest.raises(tts_asr.HTTPException) as exc:
|
||||
_run_async(tts_asr._call_tts_api("", speaker="Vivian"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_invalid_asr_audio_returns_http_exception():
|
||||
with pytest.raises(tts_asr.HTTPException) as exc:
|
||||
_run_async(tts_asr._call_asr_api(b"", language="zh-CN"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_status_config_routes(monkeypatch):
|
||||
app = FastAPI()
|
||||
app.include_router(tts_asr.meta_router)
|
||||
monkeypatch.setattr(tts_asr, "LLM_BASE_URL", "https://speech.example/v1")
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "")
|
||||
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
||||
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
||||
|
||||
with TestClient(app) as client:
|
||||
status = client.get("/status")
|
||||
config = client.get("/config")
|
||||
|
||||
assert status.status_code == 200
|
||||
assert config.status_code == 200
|
||||
assert status.json()["llm_url"] == "https://speech.example/v1"
|
||||
assert status.json()["tts_model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert status.json()["asr_model"] == "Qwen3-ASR-0.6B-8bit"
|
||||
assert status.json()["status"]["api_key_configured"] is False
|
||||
assert status.json()["status"]["max_connections"] == tts_asr.SPEECH_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_tts_concurrent_requests_respect_connection_limit(monkeypatch):
|
||||
monkeypatch.setattr(tts_asr, "SPEECH_MAX_CONNECTIONS", 4)
|
||||
monkeypatch.setattr(tts_asr, "SPEECH_MAX_KEEPALIVE_CONNECTIONS", 1)
|
||||
|
||||
class LimitedClient:
|
||||
def __init__(self):
|
||||
self.semaphore = asyncio.Semaphore(4)
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
|
||||
async def post(self, url: str, **kwargs):
|
||||
async with self.semaphore:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
await asyncio.sleep(0.01)
|
||||
self.active -= 1
|
||||
return httpx.Response(200, content=b"speech-ok", request=httpx.Request("POST", f"https://speech.example{url}"))
|
||||
|
||||
async def run():
|
||||
client = LimitedClient()
|
||||
|
||||
async def get_client():
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(tts_asr, "_get_speech_client", get_client)
|
||||
await asyncio.gather(*(tts_asr.generate_tts_response(f"文本 {index}") for index in range(20)))
|
||||
return client
|
||||
|
||||
client = _run_async(run())
|
||||
assert client.max_active <= 4
|
||||
|
||||
|
||||
def test_tts_asr_handlers_record_audit(monkeypatch):
|
||||
audit_store = _CaptureAuditStore()
|
||||
|
||||
async def fake_tts(*args, **kwargs):
|
||||
return {
|
||||
"audio_base64": base64.b64encode(b"ok").decode("utf-8"),
|
||||
"format": "wav",
|
||||
"duration_ms": 1200,
|
||||
"audio_bytes": 2,
|
||||
"text_chars": 2,
|
||||
"speaker": "Vivian",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
||||
"request_ms": 45,
|
||||
"upstream_request_id": "tts-upstream",
|
||||
}
|
||||
|
||||
async def fake_asr(*args, **kwargs):
|
||||
return {
|
||||
"text": "hello world",
|
||||
"language": "zh",
|
||||
"audio_bytes": len(_wav_bytes()),
|
||||
"model": "Qwen3-ASR-0.6B-8bit",
|
||||
"request_ms": 80,
|
||||
"upstream_request_id": "asr-upstream",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(job_handlers, "generate_tts_response", fake_tts)
|
||||
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
|
||||
monkeypatch.setattr(job_handlers, "get_audit_store", lambda *_args, **_kwargs: audit_store)
|
||||
|
||||
base_payload = {
|
||||
"request_id": "req-1",
|
||||
"risk": {
|
||||
"request_id": "req-1",
|
||||
"session_hash": "session",
|
||||
"ip_hash": "ip",
|
||||
"estimated_input_tokens": 12,
|
||||
"estimated_cost": 0.0,
|
||||
"policy": {
|
||||
"job_type": "tts",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
||||
"profile": "speech_tts",
|
||||
"max_output_tokens": 0,
|
||||
},
|
||||
},
|
||||
"job_context": {
|
||||
"created_at": 1000,
|
||||
"started_at": 1200,
|
||||
"queue_ms": 200,
|
||||
},
|
||||
}
|
||||
|
||||
async def run():
|
||||
events = []
|
||||
|
||||
async def emit(event: str, data: dict):
|
||||
events.append((event, data))
|
||||
|
||||
tts_payload = {
|
||||
**base_payload,
|
||||
"text": "你好",
|
||||
"speaker": "Vivian",
|
||||
"format": "wav",
|
||||
}
|
||||
await job_handlers.tts_handler(tts_payload, emit, lambda: False)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
|
||||
handle.write(_wav_bytes())
|
||||
audio_path = handle.name
|
||||
|
||||
try:
|
||||
asr_payload = {
|
||||
**base_payload,
|
||||
"risk": {
|
||||
**base_payload["risk"],
|
||||
"policy": {
|
||||
"job_type": "asr",
|
||||
"model": "Qwen3-ASR-0.6B-8bit",
|
||||
"profile": "speech_asr",
|
||||
"max_output_tokens": 0,
|
||||
},
|
||||
},
|
||||
"input_path": audio_path,
|
||||
"language": "zh-CN",
|
||||
}
|
||||
await job_handlers.asr_handler(asr_payload, emit, lambda: False)
|
||||
finally:
|
||||
job_handlers._safe_unlink(audio_path)
|
||||
|
||||
return events
|
||||
|
||||
events = _run_async(run())
|
||||
|
||||
assert any(event == "result" for event, _data in events)
|
||||
assert len(audit_store.llm_calls) == 2
|
||||
tts_audit = audit_store.llm_calls[0]
|
||||
asr_audit = audit_store.llm_calls[1]
|
||||
assert tts_audit["job_type"] == "tts"
|
||||
assert tts_audit["queue_ms"] == 200
|
||||
assert tts_audit["metadata"]["duration_ms"] == 1200
|
||||
assert tts_audit["metadata"]["upstream_request_id"] == "tts-upstream"
|
||||
assert asr_audit["job_type"] == "asr"
|
||||
assert asr_audit["metadata"]["language"] == "zh"
|
||||
assert asr_audit["metadata"]["upstream_request_id"] == "asr-upstream"
|
||||
@@ -0,0 +1,171 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import socket
|
||||
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(monkeypatch):
|
||||
def fake_getaddrinfo(host, port, type=0, flags=0): # noqa: ARG001
|
||||
del host, flags
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port, 0, 0))]
|
||||
|
||||
monkeypatch.setattr(job_handlers.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
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_is_blocked_public_url_resolves_private_hostname(monkeypatch):
|
||||
def fake_getaddrinfo(host, port, type=0, flags=0): # noqa: ARG001
|
||||
del host, flags
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port, 0, 0))]
|
||||
|
||||
monkeypatch.setattr(job_handlers.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
assert job_handlers._is_blocked_public_url("https://private.example.com/docs") is True
|
||||
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,371 @@
|
||||
"""OpenAI-compatible TTS/ASR adapter bound to the shared LLM API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
meta_router = APIRouter()
|
||||
|
||||
LLM_BASE_URL = (os.getenv("LLM_BASE_URL", "https://api.openai.com/v1/") or "").strip().rstrip("/")
|
||||
LLM_API_KEY = (os.getenv("LLM_API_KEY", "") or "").strip()
|
||||
|
||||
DEFAULT_TTS_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
DEFAULT_ASR_MODEL_ID = "Qwen3-ASR-0.6B-8bit"
|
||||
DEFAULT_TTS_INSTRUCTIONS = (
|
||||
os.getenv("TTS_DEFAULT_INSTRUCTIONS", "A clear, natural voice speaking Mandarin Chinese.")
|
||||
or "A clear, natural voice speaking Mandarin Chinese."
|
||||
).strip()
|
||||
|
||||
TTS_MODEL_ID = (os.getenv("TTS_MODEL_ID", DEFAULT_TTS_MODEL_ID) or DEFAULT_TTS_MODEL_ID).strip()
|
||||
ASR_MODEL_ID = (os.getenv("ASR_MODEL_ID", DEFAULT_ASR_MODEL_ID) or DEFAULT_ASR_MODEL_ID).strip()
|
||||
|
||||
TTS_MAX_TEXT_CHARS = _int_env("TTS_ASR_MAX_TEXT_CHARS", 4096)
|
||||
ASR_MAX_AUDIO_BYTES = _int_env("ASR_MAX_AUDIO_BYTES", 100 * 1024 * 1024)
|
||||
TTS_TIMEOUT_SECONDS = _int_env("TTS_ASR_TTS_TIMEOUT_SECONDS", 180)
|
||||
ASR_TIMEOUT_SECONDS = _int_env("TTS_ASR_ASR_TIMEOUT_SECONDS", 300)
|
||||
HEALTHCHECK_TIMEOUT_SECONDS = _int_env("TTS_ASR_HEALTHCHECK_TIMEOUT_SECONDS", 5)
|
||||
SPEECH_MAX_CONNECTIONS = _int_env("TTS_ASR_MAX_CONNECTIONS", 16)
|
||||
SPEECH_MAX_KEEPALIVE_CONNECTIONS = _int_env("TTS_ASR_MAX_KEEPALIVE_CONNECTIONS", 8)
|
||||
|
||||
_httpx_client: Optional[httpx.AsyncClient] = None
|
||||
_httpx_client_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _read_uint16(data: bytes, offset: int) -> Optional[int]:
|
||||
if len(data) < offset + 2:
|
||||
return None
|
||||
return int.from_bytes(data[offset : offset + 2], "little", signed=False)
|
||||
|
||||
|
||||
def _read_uint32(data: bytes, offset: int) -> Optional[int]:
|
||||
if len(data) < offset + 4:
|
||||
return None
|
||||
return int.from_bytes(data[offset : offset + 4], "little", signed=False)
|
||||
|
||||
|
||||
def _parse_wav_duration_ms(audio_bytes: bytes) -> int:
|
||||
if len(audio_bytes) < 44 or audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
|
||||
return 0
|
||||
|
||||
data_size = 0
|
||||
byte_rate = 0
|
||||
offset = 12
|
||||
|
||||
while offset + 8 <= len(audio_bytes):
|
||||
chunk_id = audio_bytes[offset : offset + 4]
|
||||
chunk_size = _read_uint32(audio_bytes, offset + 4)
|
||||
if chunk_size is None:
|
||||
break
|
||||
chunk_start = offset + 8
|
||||
chunk_end = min(chunk_start + chunk_size, len(audio_bytes))
|
||||
|
||||
if chunk_id == b"fmt ":
|
||||
audio_format = _read_uint16(audio_bytes, chunk_start)
|
||||
channels = _read_uint16(audio_bytes, chunk_start + 2)
|
||||
sample_rate = _read_uint32(audio_bytes, chunk_start + 4)
|
||||
bits_per_sample = _read_uint16(audio_bytes, chunk_start + 14)
|
||||
if audio_format == 1 and channels and sample_rate and bits_per_sample:
|
||||
byte_rate = int(sample_rate * channels * bits_per_sample // 8)
|
||||
|
||||
if chunk_id == b"data":
|
||||
data_size = chunk_size
|
||||
offset = chunk_end + (chunk_end - chunk_start) % 2
|
||||
|
||||
if data_size and byte_rate:
|
||||
return max(0, int(data_size * 1000 / byte_rate))
|
||||
return 0
|
||||
|
||||
|
||||
def _duration_from_audio_bytes(audio_bytes: bytes) -> int:
|
||||
return _parse_wav_duration_ms(audio_bytes)
|
||||
|
||||
|
||||
def _audio_bytes_to_base64(audio_bytes: bytes) -> str:
|
||||
return base64.b64encode(audio_bytes).decode("utf-8")
|
||||
|
||||
|
||||
def _normalize_tts_text(text: str) -> str:
|
||||
value = (text or "").strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=400, detail="TTS 文本为空")
|
||||
if len(value) > TTS_MAX_TEXT_CHARS:
|
||||
raise HTTPException(status_code=400, detail=f"TTS 文本过长,超过限制 {TTS_MAX_TEXT_CHARS} 个字符")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_output_format(output_format: str) -> str:
|
||||
value = (output_format or "wav").strip().lower()
|
||||
if value not in {"wav", "mp3"}:
|
||||
raise HTTPException(status_code=400, detail="不支持的 TTS 输出格式")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_asr_language(language: Optional[str]) -> Optional[str]:
|
||||
if not language:
|
||||
return None
|
||||
value = str(language).strip().lower()
|
||||
if value in {"", "auto"}:
|
||||
return None
|
||||
mapping = {
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"en-us": "en",
|
||||
"ja-jp": "ja",
|
||||
"ko-kr": "ko",
|
||||
}
|
||||
return mapping.get(value, value.split("-")[0])
|
||||
|
||||
|
||||
def _speech_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "*/*"}
|
||||
if LLM_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
|
||||
headers["X-API-Key"] = LLM_API_KEY
|
||||
return headers
|
||||
|
||||
|
||||
def _raise_http_error(response: httpx.Response, operation: str) -> None:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = (exc.response.text or "").strip()[:1000]
|
||||
detail = f"{operation} 请求失败 HTTP {exc.response.status_code}"
|
||||
if body:
|
||||
detail = f"{detail}: {body}"
|
||||
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"{operation} 请求失败: {exc}") from exc
|
||||
|
||||
|
||||
def _tts_timeout() -> httpx.Timeout:
|
||||
return httpx.Timeout(TTS_TIMEOUT_SECONDS, connect=5.0)
|
||||
|
||||
|
||||
def _asr_timeout() -> httpx.Timeout:
|
||||
return httpx.Timeout(ASR_TIMEOUT_SECONDS, connect=5.0)
|
||||
|
||||
|
||||
def _extract_upstream_request_id(response: httpx.Response) -> str:
|
||||
for header_name in ("x-request-id", "request-id", "openai-request-id"):
|
||||
value = (response.headers.get(header_name) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
async def _get_speech_client() -> httpx.AsyncClient:
|
||||
global _httpx_client
|
||||
|
||||
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
|
||||
limits = httpx.Limits(
|
||||
max_connections=SPEECH_MAX_CONNECTIONS,
|
||||
max_keepalive_connections=max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
|
||||
)
|
||||
async with _httpx_client_lock:
|
||||
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
|
||||
_httpx_client = httpx.AsyncClient(
|
||||
base_url=LLM_BASE_URL,
|
||||
timeout=_tts_timeout(),
|
||||
headers=_speech_headers(),
|
||||
follow_redirects=True,
|
||||
limits=limits,
|
||||
)
|
||||
return _httpx_client
|
||||
|
||||
|
||||
async def close_speech_client() -> None:
|
||||
global _httpx_client
|
||||
if _httpx_client is not None and not getattr(_httpx_client, "is_closed", False):
|
||||
await _httpx_client.aclose()
|
||||
_httpx_client = None
|
||||
|
||||
|
||||
async def _call_tts_api(text: str, instruct: str = "", speaker: str = "Vivian", output_format: str = "wav") -> dict[str, Any]:
|
||||
normalized_text = _normalize_tts_text(text)
|
||||
normalized_format = _normalize_output_format(output_format)
|
||||
client = await _get_speech_client()
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": TTS_MODEL_ID,
|
||||
"input": normalized_text,
|
||||
"response_format": normalized_format,
|
||||
"voice": speaker or "Vivian",
|
||||
}
|
||||
payload["instructions"] = (instruct or "").strip() or DEFAULT_TTS_INSTRUCTIONS
|
||||
|
||||
started_at = time.perf_counter()
|
||||
response = await client.post("audio/speech", json=payload, timeout=_tts_timeout(), headers=_speech_headers())
|
||||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
_raise_http_error(response, "TTS")
|
||||
audio_bytes = response.content
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=502, detail="TTS API 返回音频为空")
|
||||
return {
|
||||
"audio_bytes": audio_bytes,
|
||||
"request_ms": elapsed_ms,
|
||||
"upstream_request_id": _extract_upstream_request_id(response),
|
||||
}
|
||||
|
||||
|
||||
async def _call_asr_api(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="ASR 音频内容为空")
|
||||
if len(audio_bytes) > ASR_MAX_AUDIO_BYTES:
|
||||
raise HTTPException(status_code=400, detail=f"ASR 音频过大,超过限制 {ASR_MAX_AUDIO_BYTES} 字节")
|
||||
|
||||
normalized_language = _normalize_asr_language(language)
|
||||
client = await _get_speech_client()
|
||||
files = {"file": ("audio.wav", audio_bytes, "audio/wav")}
|
||||
data = {"model": ASR_MODEL_ID}
|
||||
if normalized_language:
|
||||
data["language"] = normalized_language
|
||||
|
||||
started_at = time.perf_counter()
|
||||
response = await client.post(
|
||||
"audio/transcriptions",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=_asr_timeout(),
|
||||
headers=_speech_headers(),
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
_raise_http_error(response, "ASR")
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=502, detail="ASR API 返回非 JSON 数据") from exc
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise HTTPException(status_code=502, detail="ASR API 返回结构异常")
|
||||
|
||||
text = str(result.get("text", "") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="ASR API 返回结果为空")
|
||||
|
||||
detected_language = result.get("language") or normalized_language or "auto"
|
||||
return {
|
||||
"text": text,
|
||||
"language": str(detected_language),
|
||||
"request_ms": elapsed_ms,
|
||||
"upstream_request_id": _extract_upstream_request_id(response),
|
||||
}
|
||||
|
||||
|
||||
async def generate_tts_response(
|
||||
text: str,
|
||||
instruct: str = "",
|
||||
speaker: str = "Vivian",
|
||||
output_format: str = "wav",
|
||||
) -> dict[str, Any]:
|
||||
result = await _call_tts_api(
|
||||
text=text,
|
||||
instruct=instruct or "",
|
||||
speaker=speaker or "Vivian",
|
||||
output_format=output_format or "wav",
|
||||
)
|
||||
audio_bytes = bytes(result["audio_bytes"])
|
||||
return {
|
||||
"audio_base64": _audio_bytes_to_base64(audio_bytes),
|
||||
"format": _normalize_output_format(output_format or "wav"),
|
||||
"duration_ms": _duration_from_audio_bytes(audio_bytes),
|
||||
"audio_bytes": len(audio_bytes),
|
||||
"text_chars": len(_normalize_tts_text(text)),
|
||||
"speaker": speaker or "Vivian",
|
||||
"model": TTS_MODEL_ID,
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
|
||||
result = await _call_asr_api(bytes(audio_bytes or b""), language or "zh-CN")
|
||||
return {
|
||||
"text": str(result["text"]),
|
||||
"language": str(result["language"]),
|
||||
"audio_bytes": len(audio_bytes or b""),
|
||||
"model": ASR_MODEL_ID,
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str = ""
|
||||
format: str = "wav"
|
||||
duration_ms: int = 0
|
||||
audio_bytes: int = 0
|
||||
text_chars: int = 0
|
||||
speaker: str = "Vivian"
|
||||
model: str = TTS_MODEL_ID
|
||||
request_ms: int = 0
|
||||
upstream_request_id: str = ""
|
||||
|
||||
|
||||
class ASRResponse(BaseModel):
|
||||
text: str = ""
|
||||
language: Optional[str] = None
|
||||
audio_bytes: int = 0
|
||||
model: str = ASR_MODEL_ID
|
||||
request_ms: int = 0
|
||||
upstream_request_id: str = ""
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
llm_url: str
|
||||
tts_model: str
|
||||
asr_model: str
|
||||
status: dict[str, Any]
|
||||
|
||||
|
||||
def _status_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"llm_url": LLM_BASE_URL or "",
|
||||
"tts_model": TTS_MODEL_ID,
|
||||
"asr_model": ASR_MODEL_ID,
|
||||
"status": {
|
||||
"api_configured": bool(LLM_BASE_URL),
|
||||
"api_key_configured": bool(LLM_API_KEY),
|
||||
"tts_model": TTS_MODEL_ID,
|
||||
"asr_model": ASR_MODEL_ID,
|
||||
"tts_timeout_seconds": TTS_TIMEOUT_SECONDS,
|
||||
"asr_timeout_seconds": ASR_TIMEOUT_SECONDS,
|
||||
"healthcheck_timeout_seconds": HEALTHCHECK_TIMEOUT_SECONDS,
|
||||
"max_connections": SPEECH_MAX_CONNECTIONS,
|
||||
"keepalive_connections": max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
|
||||
"max_tts_text_chars": TTS_MAX_TEXT_CHARS,
|
||||
"max_asr_audio_bytes": ASR_MAX_AUDIO_BYTES,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@meta_router.get("/status", response_model=ModelStatus)
|
||||
async def get_status():
|
||||
return _status_payload()
|
||||
|
||||
|
||||
@meta_router.get("/config")
|
||||
async def get_config():
|
||||
return _status_payload()
|
||||
|
||||
|
||||
def register_tts_asr_routes(app) -> None:
|
||||
app.include_router(meta_router, prefix="/v1/tts-asr")
|
||||
@@ -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())
|
||||
@@ -1,76 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../../platform/telemetry/common/telemetry';
|
||||
import { wrapEventNameForPrefixRemoval } from '../../../../../platform/telemetry/node/azureInsightsReporter';
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { TelemetryMeasurements, TelemetryProperties, TelemetryStore } from '../../lib/src/telemetry';
|
||||
import type { TelemetrySpy } from '../../lib/src/test/telemetrySpy';
|
||||
|
||||
export const ICompletionsTelemetryService = createServiceIdentifier<ICompletionsTelemetryService>('completionsTelemetryService');
|
||||
export interface ICompletionsTelemetryService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
sendGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void;
|
||||
sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void;
|
||||
sendGHTelemetryErrorEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void;
|
||||
sendGHTelemetryException(maybeError: unknown, origin: string, store?: TelemetryStore): void;
|
||||
setSpyReporters(reporter: TelemetrySpy, enhancedReporter: TelemetrySpy): void;
|
||||
clearSpyReporters(): void;
|
||||
}
|
||||
|
||||
export class CompletionsTelemetryServiceBridge implements ICompletionsTelemetryService {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
private reporter: TelemetrySpy | undefined;
|
||||
private enhancedReporter: TelemetrySpy | undefined;
|
||||
|
||||
constructor(
|
||||
@ITelemetryService private readonly telemetryService: ITelemetryService
|
||||
) {
|
||||
this.reporter = undefined;
|
||||
this.enhancedReporter = undefined;
|
||||
}
|
||||
|
||||
sendGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void {
|
||||
this.telemetryService.sendGHTelemetryEvent(wrapEventNameForPrefixRemoval(`copilot/${eventName}`), properties, measurements);
|
||||
this.getSpyReporters(store ?? TelemetryStore.Standard)?.sendTelemetryEvent(eventName, properties as TelemetryProperties, measurements as TelemetryMeasurements);
|
||||
}
|
||||
|
||||
sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void {
|
||||
this.telemetryService.sendEnhancedGHTelemetryEvent(wrapEventNameForPrefixRemoval(`copilot/${eventName}`), properties, measurements);
|
||||
this.getSpyReporters(store ?? TelemetryStore.Enhanced)?.sendTelemetryEvent(eventName, properties as TelemetryProperties, measurements as TelemetryMeasurements);
|
||||
}
|
||||
|
||||
sendGHTelemetryErrorEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements, store?: TelemetryStore): void {
|
||||
this.telemetryService.sendGHTelemetryErrorEvent(wrapEventNameForPrefixRemoval(`copilot/${eventName}`), properties, measurements);
|
||||
this.getSpyReporters(store ?? TelemetryStore.Enhanced)?.sendTelemetryErrorEvent(eventName, properties as TelemetryProperties, measurements as TelemetryMeasurements);
|
||||
}
|
||||
|
||||
sendGHTelemetryException(maybeError: unknown, origin: string, store?: TelemetryStore): void {
|
||||
this.telemetryService.sendGHTelemetryException(maybeError, origin);
|
||||
if (maybeError instanceof Error) {
|
||||
this.getSpyReporters(store ?? TelemetryStore.Enhanced)?.sendTelemetryException(maybeError as Error, undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setSpyReporters(reporter: TelemetrySpy, enhancedReporter: TelemetrySpy) {
|
||||
this.reporter = reporter;
|
||||
this.enhancedReporter = enhancedReporter;
|
||||
}
|
||||
|
||||
clearSpyReporters() {
|
||||
this.reporter = undefined;
|
||||
this.enhancedReporter = undefined;
|
||||
}
|
||||
|
||||
private getSpyReporters(store: TelemetryStore): TelemetrySpy | undefined {
|
||||
if (TelemetryStore.isEnhanced(store)) {
|
||||
return this.enhancedReporter;
|
||||
} else {
|
||||
return this.reporter;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { commands, env } from 'vscode';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget';
|
||||
import { DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ServiceCollection } from '../../../util/vs/platform/instantiation/common/serviceCollection';
|
||||
import { CompletionsTelemetryServiceBridge, ICompletionsTelemetryService } from './bridge/src/completionsTelemetryServiceBridge';
|
||||
import { LoggingCitationManager } from './extension/src/codeReferencing/citationManager';
|
||||
import { CompletionsObservableWorkspace } from './extension/src/completionsObservableWorkspace';
|
||||
import { disableCompletions, enableCompletions, toggleCompletions, VSCodeConfigProvider, VSCodeEditorInfo } from './extension/src/config';
|
||||
import { CMDDisableCompletionsChat, CMDDisableCompletionsClient, CMDEnableCompletionsChat, CMDEnableCompletionsClient, CMDOpenDocumentationClient, CMDOpenLogsClient, CMDOpenModelPickerChat, CMDOpenModelPickerClient, CMDToggleCompletionsChat, CMDToggleCompletionsClient, CMDToggleStatusMenuChat, CMDToggleStatusMenuClient } from './extension/src/constants';
|
||||
import { contextProviderMatch } from './extension/src/contextProviderMatch';
|
||||
import { registerPanelSupport } from './extension/src/copilotPanel/common';
|
||||
import { CopilotExtensionStatus, ICompletionsExtensionStatus } from './extension/src/extensionStatus';
|
||||
import { extensionFileSystem } from './extension/src/fileSystem';
|
||||
import { ModelPickerManager } from './extension/src/modelPicker';
|
||||
import { CopilotStatusBar } from './extension/src/statusBar';
|
||||
import { CopilotStatusBarPickMenu } from './extension/src/statusBarPicker';
|
||||
import { ExtensionTextDocumentManager } from './extension/src/textDocumentManager';
|
||||
import { exception } from './extension/src/vscodeInlineCompletionItemProvider';
|
||||
import { CopilotTokenManagerImpl, ICompletionsCopilotTokenManager } from './lib/src/auth/copilotTokenManager';
|
||||
import { ICompletionsCitationManager } from './lib/src/citationManager';
|
||||
import { CompletionNotifier, ICompletionsNotifierService } from './lib/src/completionNotifier';
|
||||
import { ICompletionsObservableWorkspace } from './lib/src/completionsObservableWorkspace';
|
||||
import { ICompletionsConfigProvider, ICompletionsEditorAndPluginInfo } from './lib/src/config';
|
||||
import { registerDocumentTracker } from './lib/src/documentTracker';
|
||||
import { ICompletionsUserErrorNotifierService, UserErrorNotifier } from './lib/src/error/userErrorNotifier';
|
||||
import { setupCompletionsExperimentationService } from './lib/src/experiments/defaultExpFilters';
|
||||
import { Features } from './lib/src/experiments/features';
|
||||
import { ICompletionsFeaturesService } from './lib/src/experiments/featuresService';
|
||||
import { FileReader, ICompletionsFileReaderService } from './lib/src/fileReader';
|
||||
import { ICompletionsFileSystemService } from './lib/src/fileSystem';
|
||||
import { AsyncCompletionManager, ICompletionsAsyncManagerService } from './lib/src/ghostText/asyncCompletions';
|
||||
import { CompletionsCache, ICompletionsCacheService } from './lib/src/ghostText/completionsCache';
|
||||
import { ConfigBlockModeConfig, ICompletionsBlockModeConfig } from './lib/src/ghostText/configBlockMode';
|
||||
import { CurrentGhostText, ICompletionsCurrentGhostText } from './lib/src/ghostText/current';
|
||||
import { ICompletionsLastGhostText, LastGhostText } from './lib/src/ghostText/last';
|
||||
import { ICompletionsSpeculativeRequestCache, SpeculativeRequestCache } from './lib/src/ghostText/speculativeRequestCache';
|
||||
import { ICompletionsLogTargetService, LogLevel } from './lib/src/logger';
|
||||
import { formatLogMessage } from './lib/src/logging/util';
|
||||
import { CompletionsFetcher, ICompletionsFetcherService } from './lib/src/networking';
|
||||
import { ExtensionNotificationSender, ICompletionsNotificationSender } from './lib/src/notificationSender';
|
||||
import { ICompletionsOpenAIFetcherService, LiveOpenAIFetcher } from './lib/src/openai/fetch';
|
||||
import { AvailableModelsManager, ICompletionsModelManagerService } from './lib/src/openai/model';
|
||||
import { ICompletionsStatusReporter } from './lib/src/progress';
|
||||
import {
|
||||
CompletionsPromptFactory, ICompletionsPromptFactoryService
|
||||
} from './lib/src/prompt/completionsPromptFactory/completionsPromptFactory';
|
||||
import { ContextProviderBridge, ICompletionsContextProviderBridgeService } from './lib/src/prompt/components/contextProviderBridge';
|
||||
import {
|
||||
CachedContextProviderRegistry,
|
||||
CoreContextProviderRegistry,
|
||||
DefaultContextProvidersContainer, ICompletionsContextProviderRegistryService,
|
||||
ICompletionsDefaultContextProviders
|
||||
} from './lib/src/prompt/contextProviderRegistry';
|
||||
import { ContextProviderStatistics, ICompletionsContextProviderService } from './lib/src/prompt/contextProviderStatistics';
|
||||
import { FullRecentEditsProvider, ICompletionsRecentEditsProviderService } from './lib/src/prompt/recentEdits/recentEditsProvider';
|
||||
import { CompositeRelatedFilesProvider } from './lib/src/prompt/similarFiles/compositeRelatedFilesProvider';
|
||||
import { ICompletionsRelatedFilesProviderService } from './lib/src/prompt/similarFiles/relatedFiles';
|
||||
import { ICompletionsTelemetryUserConfigService, TelemetryUserConfig } from './lib/src/telemetry/userConfig';
|
||||
import { ICompletionsTextDocumentManagerService } from './lib/src/textDocumentManager';
|
||||
import { ICompletionsPromiseQueueService, PromiseQueue } from './lib/src/util/promiseQueue';
|
||||
import { ICompletionsRuntimeModeService, RuntimeMode } from './lib/src/util/runtimeMode';
|
||||
|
||||
/** @public */
|
||||
export function createContext(serviceAccessor: ServicesAccessor, store: DisposableStore): IInstantiationService {
|
||||
const logService = serviceAccessor.get(ILogService);
|
||||
|
||||
const serviceCollection = new ServiceCollection();
|
||||
|
||||
serviceCollection.set(ICompletionsLogTargetService, new class implements ICompletionsLogTargetService {
|
||||
declare _serviceBrand: undefined;
|
||||
logIt(level: LogLevel, category: string, ...extra: unknown[]): void {
|
||||
const msg = formatLogMessage(category, ...extra);
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG: return logService.debug(msg);
|
||||
case LogLevel.INFO: return logService.info(msg);
|
||||
case LogLevel.WARN: return logService.warn(msg);
|
||||
case LogLevel.ERROR: return logService.error(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serviceCollection.set(ICompletionsRuntimeModeService, RuntimeMode.fromEnvironment(false));
|
||||
serviceCollection.set(ICompletionsCacheService, new CompletionsCache());
|
||||
serviceCollection.set(ICompletionsConfigProvider, new VSCodeConfigProvider());
|
||||
serviceCollection.set(ICompletionsLastGhostText, new LastGhostText());
|
||||
serviceCollection.set(ICompletionsCurrentGhostText, new CurrentGhostText());
|
||||
serviceCollection.set(ICompletionsSpeculativeRequestCache, new SpeculativeRequestCache());
|
||||
serviceCollection.set(ICompletionsNotificationSender, new SyncDescriptor(ExtensionNotificationSender));
|
||||
serviceCollection.set(ICompletionsEditorAndPluginInfo, new VSCodeEditorInfo());
|
||||
serviceCollection.set(ICompletionsExtensionStatus, new CopilotExtensionStatus());
|
||||
serviceCollection.set(ICompletionsFeaturesService, new SyncDescriptor(Features));
|
||||
serviceCollection.set(ICompletionsObservableWorkspace, new SyncDescriptor(CompletionsObservableWorkspace));
|
||||
serviceCollection.set(ICompletionsStatusReporter, new SyncDescriptor(CopilotStatusBar, ['github.copilot.languageStatus']));
|
||||
serviceCollection.set(ICompletionsCopilotTokenManager, new SyncDescriptor(CopilotTokenManagerImpl, [false]));
|
||||
serviceCollection.set(ICompletionsTextDocumentManagerService, new SyncDescriptor(ExtensionTextDocumentManager));
|
||||
serviceCollection.set(ICompletionsFileReaderService, new SyncDescriptor(FileReader));
|
||||
serviceCollection.set(ICompletionsBlockModeConfig, new SyncDescriptor(ConfigBlockModeConfig));
|
||||
serviceCollection.set(ICompletionsTelemetryService, new SyncDescriptor(CompletionsTelemetryServiceBridge));
|
||||
serviceCollection.set(ICompletionsTelemetryUserConfigService, new SyncDescriptor(TelemetryUserConfig));
|
||||
serviceCollection.set(ICompletionsRecentEditsProviderService, new SyncDescriptor(FullRecentEditsProvider, [undefined]));
|
||||
serviceCollection.set(ICompletionsNotifierService, new SyncDescriptor(CompletionNotifier));
|
||||
serviceCollection.set(ICompletionsOpenAIFetcherService, new SyncDescriptor(LiveOpenAIFetcher));
|
||||
serviceCollection.set(ICompletionsModelManagerService, new SyncDescriptor(AvailableModelsManager, [true]));
|
||||
serviceCollection.set(ICompletionsAsyncManagerService, new SyncDescriptor(AsyncCompletionManager));
|
||||
serviceCollection.set(ICompletionsContextProviderBridgeService, new SyncDescriptor(ContextProviderBridge));
|
||||
serviceCollection.set(ICompletionsUserErrorNotifierService, new SyncDescriptor(UserErrorNotifier));
|
||||
serviceCollection.set(ICompletionsRelatedFilesProviderService, new SyncDescriptor(CompositeRelatedFilesProvider));
|
||||
serviceCollection.set(ICompletionsFileSystemService, extensionFileSystem);
|
||||
serviceCollection.set(ICompletionsContextProviderRegistryService, new SyncDescriptor(CachedContextProviderRegistry, [CoreContextProviderRegistry, contextProviderMatch]));
|
||||
serviceCollection.set(ICompletionsPromiseQueueService, new PromiseQueue());
|
||||
serviceCollection.set(ICompletionsCitationManager, new SyncDescriptor(LoggingCitationManager));
|
||||
serviceCollection.set(ICompletionsContextProviderService, new ContextProviderStatistics());
|
||||
serviceCollection.set(ICompletionsPromptFactoryService, new SyncDescriptor(CompletionsPromptFactory));
|
||||
serviceCollection.set(ICompletionsFetcherService, new SyncDescriptor(CompletionsFetcher));
|
||||
serviceCollection.set(ICompletionsDefaultContextProviders, new DefaultContextProvidersContainer());
|
||||
|
||||
return serviceAccessor.get(IInstantiationService).createChild(serviceCollection, store);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function setup(serviceAccessor: ServicesAccessor, disposables: DisposableStore) {
|
||||
// This must be registered before activation!
|
||||
// CodeQuote needs to listen for the initial token notification event.
|
||||
disposables.add(serviceAccessor.get(ICompletionsCitationManager).register());
|
||||
|
||||
// Register to listen for changes to the active document to keep track
|
||||
// of last access time
|
||||
disposables.add(registerDocumentTracker(serviceAccessor));
|
||||
|
||||
// Register the context providers enabled by default.
|
||||
const defaultContextProviders = serviceAccessor.get(ICompletionsDefaultContextProviders);
|
||||
defaultContextProviders.add('ms-vscode.cpptools');
|
||||
defaultContextProviders.add('promptfile-ai-context-provider');
|
||||
|
||||
disposables.add(setupCompletionsExperimentationService(serviceAccessor));
|
||||
}
|
||||
|
||||
export function registerUnificationCommands(accessor: ServicesAccessor): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
disposables.add(registerEnablementCommands(accessor));
|
||||
disposables.add(registerStatusBar(accessor));
|
||||
disposables.add(registerDiagnosticCommands(accessor));
|
||||
disposables.add(registerPanelSupport(accessor));
|
||||
disposables.add(registerModelPickerCommands(accessor));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
function registerEnablementCommands(accessor: ServicesAccessor): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
// Enable/Disable/Toggle completions commands [with Command Palette support]
|
||||
function enable(id: string): IDisposable {
|
||||
return registerCommandWrapper(accessor, id, async () => {
|
||||
await instantiationService.invokeFunction(enableCompletions);
|
||||
});
|
||||
}
|
||||
function disable(id: string): IDisposable {
|
||||
return registerCommandWrapper(accessor, id, async () => {
|
||||
await instantiationService.invokeFunction(disableCompletions);
|
||||
});
|
||||
}
|
||||
function toggle(id: string): IDisposable {
|
||||
return registerCommandWrapper(accessor, id, async () => {
|
||||
await instantiationService.invokeFunction(toggleCompletions);
|
||||
});
|
||||
}
|
||||
|
||||
// To support command palette
|
||||
disposables.add(enable(CMDEnableCompletionsChat));
|
||||
disposables.add(disable(CMDDisableCompletionsChat));
|
||||
disposables.add(toggle(CMDToggleCompletionsChat));
|
||||
|
||||
// To support keybindings/main functionality
|
||||
disposables.add(enable(CMDEnableCompletionsClient));
|
||||
disposables.add(disable(CMDDisableCompletionsClient));
|
||||
disposables.add(toggle(CMDToggleCompletionsClient));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
function registerModelPickerCommands(accessor: ServicesAccessor): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
const modelsPicker = instantiationService.createInstance(ModelPickerManager);
|
||||
|
||||
function registerModelPicker(commandId: string): IDisposable {
|
||||
return registerCommandWrapper(accessor, commandId, async () => {
|
||||
await modelsPicker.showModelPicker();
|
||||
});
|
||||
}
|
||||
|
||||
// Model picker command [with Command Palette support]
|
||||
disposables.add(registerModelPicker(CMDOpenModelPickerClient));
|
||||
disposables.add(registerModelPicker(CMDOpenModelPickerChat));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
function registerStatusBar(accessor: ServicesAccessor): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const copilotTokenManagerService = accessor.get(ICompletionsCopilotTokenManager);
|
||||
const extensionStatusService = accessor.get(ICompletionsExtensionStatus);
|
||||
|
||||
// Status menu command [with Command Palette support]
|
||||
function registerStatusMenu(menuId: string): IDisposable {
|
||||
return registerCommandWrapper(accessor, menuId, async () => {
|
||||
if (extensionStatusService.kind === 'Error') {
|
||||
// Try for a fresh token to clear up the error, but don't block the UI for too long.
|
||||
await Promise.race([
|
||||
copilotTokenManagerService.primeToken(),
|
||||
new Promise(resolve => setTimeout(resolve, 100)),
|
||||
]);
|
||||
}
|
||||
instantiationService.createInstance(CopilotStatusBarPickMenu).showStatusMenu();
|
||||
});
|
||||
}
|
||||
disposables.add(registerStatusMenu(CMDToggleStatusMenuClient));
|
||||
disposables.add(registerStatusMenu(CMDToggleStatusMenuChat));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
function registerDiagnosticCommands(accessor: ServicesAccessor): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
disposables.add(registerCommandWrapper(accessor, CMDOpenDocumentationClient, () => {
|
||||
return env.openExternal(
|
||||
URI.parse('https://docs.github.com/en/copilot/getting-started-with-github-copilot?tool=vscode')
|
||||
);
|
||||
}));
|
||||
disposables.add(registerCommandWrapper(accessor, CMDOpenLogsClient, () => {
|
||||
outputChannel.show();
|
||||
}));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
export function registerCommandWrapper(accessor: ServicesAccessor, command: string, fn: (...args: unknown[]) => unknown): IDisposable {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
return commands.registerCommand(command, async (...args: unknown[]) => {
|
||||
try {
|
||||
await fn(...args);
|
||||
} catch (error) {
|
||||
instantiationService.invokeFunction(exception, error, command);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { commands } from 'vscode';
|
||||
import { CodeReference } from '.';
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { Disposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { onCopilotToken } from '../../../lib/src/auth/copilotTokenNotifier';
|
||||
import { ICompletionsCitationManager, IPDocumentCitation } from '../../../lib/src/citationManager';
|
||||
import { OutputPaneShowCommand } from '../../../lib/src/snippy/constants';
|
||||
import { copilotOutputLogTelemetry } from '../../../lib/src/snippy/telemetryHandlers';
|
||||
import { notify } from './matchNotifier';
|
||||
import { GitHubCopilotLogger } from './outputChannel';
|
||||
|
||||
/**
|
||||
* Citation manager that logs citations to the VS Code log. On the first citation encountered,
|
||||
* the user gets a notification.
|
||||
*/
|
||||
export class LoggingCitationManager extends Disposable implements ICompletionsCitationManager {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
private logger?: GitHubCopilotLogger;
|
||||
private readonly codeReference: CodeReference;
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IAuthenticationService authenticationService: IAuthenticationService,
|
||||
) {
|
||||
super();
|
||||
this.codeReference = this._register(this.instantiationService.createInstance(CodeReference));
|
||||
const disposable = onCopilotToken(authenticationService, _ => {
|
||||
if (this.logger) {
|
||||
return;
|
||||
}
|
||||
this.logger = instantiationService.createInstance(GitHubCopilotLogger);
|
||||
const initialNotificationCommand = commands.registerCommand(OutputPaneShowCommand, () =>
|
||||
this.logger?.forceShow()
|
||||
);
|
||||
this.codeReference.addDisposable(initialNotificationCommand);
|
||||
});
|
||||
this.codeReference.addDisposable(disposable);
|
||||
}
|
||||
|
||||
register() {
|
||||
return this.codeReference.register();
|
||||
}
|
||||
|
||||
async handleIPCodeCitation(citation: IPDocumentCitation): Promise<void> {
|
||||
if (!this.codeReference.enabled || !this.logger || citation.details.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = citation.location?.start;
|
||||
const matchLocation = start ? `[Ln ${start.line + 1}, Col ${start.character + 1}]` : 'Location not available';
|
||||
const shortenedMatchText = `${citation.matchingText
|
||||
?.slice(0, 100)
|
||||
.replace(/[\r\n\t]+|^[ \t]+/gm, ' ')
|
||||
.trim()}...`;
|
||||
|
||||
this.logger.info(citation.inDocumentUri, `Similar code at `, matchLocation, shortenedMatchText);
|
||||
for (const detail of citation.details) {
|
||||
const { license, url } = detail;
|
||||
this.logger.info(`License: ${license.replace('NOASSERTION', 'unknown')}, URL: ${url}`);
|
||||
}
|
||||
copilotOutputLogTelemetry.handleWrite({ instantiationService: this.instantiationService });
|
||||
await this.instantiationService.invokeFunction(notify);
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { TextEditor, window } from 'vscode';
|
||||
import { Disposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { copilotOutputLogTelemetry } from '../../../lib/src/snippy/telemetryHandlers';
|
||||
import { citationsChannelName } from './outputChannel';
|
||||
|
||||
export class CodeRefEngagementTracker extends Disposable {
|
||||
private activeLog = false;
|
||||
|
||||
constructor(@IInstantiationService private instantiationService: IInstantiationService) {
|
||||
super();
|
||||
this._register(window.onDidChangeActiveTextEditor((e) => this.onActiveEditorChange(e)));
|
||||
this._register(window.onDidChangeVisibleTextEditors((e) => this.onVisibleEditorsChange(e)));
|
||||
}
|
||||
|
||||
onActiveEditorChange = (editor: TextEditor | undefined) => {
|
||||
if (this.isOutputLog(editor)) {
|
||||
copilotOutputLogTelemetry.handleFocus({ instantiationService: this.instantiationService });
|
||||
}
|
||||
};
|
||||
|
||||
onVisibleEditorsChange = (currEditors: readonly TextEditor[]) => {
|
||||
const copilotLog = currEditors.find(e => this.isOutputLog(e));
|
||||
|
||||
if (this.activeLog) {
|
||||
if (!copilotLog) {
|
||||
this.activeLog = false;
|
||||
}
|
||||
} else if (copilotLog) {
|
||||
this.activeLog = true;
|
||||
copilotOutputLogTelemetry.handleOpen({ instantiationService: this.instantiationService });
|
||||
}
|
||||
};
|
||||
|
||||
get logVisible() {
|
||||
return this.activeLog;
|
||||
}
|
||||
|
||||
private isOutputLog = (editor: TextEditor | undefined) => {
|
||||
return (
|
||||
editor && editor.document.uri.scheme === 'output' && editor.document.uri.path.includes(citationsChannelName)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable } from 'vscode';
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { IDisposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CopilotToken } from '../../../lib/src/auth/copilotTokenManager';
|
||||
import { onCopilotToken } from '../../../lib/src/auth/copilotTokenNotifier';
|
||||
import { ICompletionsLogTargetService } from '../../../lib/src/logger';
|
||||
import { codeReferenceLogger } from '../../../lib/src/snippy/logger';
|
||||
import { ICompletionsRuntimeModeService } from '../../../lib/src/util/runtimeMode';
|
||||
import { CodeRefEngagementTracker } from './codeReferenceEngagementTracker';
|
||||
|
||||
export class CodeReference implements IDisposable {
|
||||
subscriptions: Disposable | undefined;
|
||||
event?: Disposable;
|
||||
enabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@ICompletionsRuntimeModeService readonly _runtimeMode: ICompletionsRuntimeModeService,
|
||||
@ICompletionsLogTargetService private readonly _logTarget: ICompletionsLogTargetService,
|
||||
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
|
||||
) { }
|
||||
|
||||
dispose() {
|
||||
this.subscriptions?.dispose();
|
||||
this.event?.dispose();
|
||||
}
|
||||
|
||||
register() {
|
||||
if (!this._runtimeMode.isRunningInTest()) {
|
||||
this.event = onCopilotToken(this._authenticationService, (t) => this.onCopilotToken(t));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
addDisposable(disposable: Disposable) {
|
||||
if (!this.subscriptions) {
|
||||
this.subscriptions = Disposable.from(disposable);
|
||||
} else {
|
||||
this.subscriptions = Disposable.from(this.subscriptions, disposable);
|
||||
}
|
||||
}
|
||||
|
||||
onCopilotToken = (token: Omit<CopilotToken, 'token'>) => {
|
||||
this.enabled = token.codeQuoteEnabled || false;
|
||||
if (!token.codeQuoteEnabled) {
|
||||
this.subscriptions?.dispose();
|
||||
this.subscriptions = undefined;
|
||||
codeReferenceLogger.debug(this._logTarget, 'Public code references are disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
codeReferenceLogger.info(this._logTarget, 'Public code references are enabled.');
|
||||
this.addDisposable(this._instantiationService.createInstance(CodeRefEngagementTracker));
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { commands, env, Uri } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../../../platform/extContext/common/extensionContext';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsNotificationSender } from '../../../lib/src/notificationSender';
|
||||
import { OutputPaneShowCommand } from '../../../lib/src/snippy/constants';
|
||||
import { matchNotificationTelemetry, TelemetryActor } from '../../../lib/src/snippy/telemetryHandlers';
|
||||
|
||||
const matchCodeMessage =
|
||||
'We found a reference to public code in a recent suggestion. To learn more about public code references, review the [documentation](https://aka.ms/github-copilot-match-public-code).';
|
||||
const MatchAction = 'View reference';
|
||||
const SettingAction = 'Change setting';
|
||||
const CodeReferenceKey = 'codeReference.notified';
|
||||
|
||||
/**
|
||||
* Displays a toast notification when the first code reference is found.
|
||||
* The user will only ever see a single notification of this behavior.
|
||||
* Displays the output panel on notification ack.
|
||||
*/
|
||||
export function notify(accessor: ServicesAccessor) {
|
||||
const extension = accessor.get(IVSCodeExtensionContext);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const didNotify = extension.globalState.get<boolean>(CodeReferenceKey);
|
||||
|
||||
if (didNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationSender = accessor.get(ICompletionsNotificationSender);
|
||||
|
||||
const messageItems = [{ title: MatchAction }, { title: SettingAction }];
|
||||
|
||||
void notificationSender.showWarningMessage(matchCodeMessage, ...messageItems).then(async action => {
|
||||
const event = { instantiationService, actor: 'user' as TelemetryActor };
|
||||
|
||||
switch (action?.title) {
|
||||
case MatchAction: {
|
||||
matchNotificationTelemetry.handleDoAction(event);
|
||||
await commands.executeCommand(OutputPaneShowCommand);
|
||||
break;
|
||||
}
|
||||
case SettingAction: {
|
||||
await env.openExternal(Uri.parse('https://aka.ms/github-copilot-settings'));
|
||||
break;
|
||||
}
|
||||
case undefined: {
|
||||
matchNotificationTelemetry.handleDismiss(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return extension.globalState.update(CodeReferenceKey, true);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { window, type OutputChannel } from 'vscode';
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { Disposable, IDisposable, MutableDisposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CopilotToken } from '../../../lib/src/auth/copilotTokenManager';
|
||||
import { onCopilotToken } from '../../../lib/src/auth/copilotTokenNotifier';
|
||||
|
||||
interface GitHubLogger extends Disposable {
|
||||
info(...messages: string[]): void;
|
||||
forceShow(): void;
|
||||
}
|
||||
|
||||
export const citationsChannelName = 'GitHub Copilot Log (Code References)';
|
||||
|
||||
// Literally taken from VS Code
|
||||
function getCurrentTimestamp() {
|
||||
const toTwoDigits = (v: number) => (v < 10 ? `0${v}` : v);
|
||||
const toThreeDigits = (v: number) => (v < 10 ? `00${v}` : v < 100 ? `0${v}` : v);
|
||||
const currentTime = new Date();
|
||||
return `${currentTime.getFullYear()}-${toTwoDigits(currentTime.getMonth() + 1)}-${toTwoDigits(
|
||||
currentTime.getDate()
|
||||
)} ${toTwoDigits(currentTime.getHours())}:${toTwoDigits(currentTime.getMinutes())}:${toTwoDigits(
|
||||
currentTime.getSeconds()
|
||||
)}.${toThreeDigits(currentTime.getMilliseconds())}`;
|
||||
}
|
||||
|
||||
class CodeReferenceOutputChannel implements IDisposable {
|
||||
constructor(private output: OutputChannel) { }
|
||||
|
||||
info(...messages: string[]) {
|
||||
this.output.appendLine(`${getCurrentTimestamp()} [info] ${messages.join(' ')}`);
|
||||
}
|
||||
|
||||
show(preserveFocus: boolean) {
|
||||
this.output.show(preserveFocus);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.output.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubCopilotLogger extends Disposable implements GitHubLogger {
|
||||
|
||||
private output = this._register(new MutableDisposable<CodeReferenceOutputChannel>());
|
||||
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IAuthenticationService authenticationService: IAuthenticationService
|
||||
) {
|
||||
super();
|
||||
this._register(onCopilotToken(authenticationService, t => this.checkCopilotToken(t)));
|
||||
|
||||
this.createChannel();
|
||||
}
|
||||
|
||||
private checkCopilotToken = (token: Omit<CopilotToken, 'token'>) => {
|
||||
if (token.codeQuoteEnabled) {
|
||||
this.createChannel();
|
||||
} else {
|
||||
this.removeChannel();
|
||||
}
|
||||
};
|
||||
|
||||
private log(type: 'info', ...messages: string[]) {
|
||||
const output = this.createChannel();
|
||||
|
||||
const [base, ...rest] = messages;
|
||||
output[type](base, ...rest);
|
||||
}
|
||||
|
||||
info(...messages: string[]) {
|
||||
this.log('info', ...messages);
|
||||
}
|
||||
|
||||
forceShow() {
|
||||
// Preserve focus in the editor
|
||||
this.getChannel()?.show(true);
|
||||
}
|
||||
|
||||
private createChannel(): CodeReferenceOutputChannel {
|
||||
if (this.output.value) {
|
||||
return this.output.value;
|
||||
}
|
||||
|
||||
this.output.value = new CodeReferenceOutputChannel(window.createOutputChannel(citationsChannelName, 'code-referencing'));
|
||||
return this.output.value;
|
||||
}
|
||||
|
||||
private getChannel(): CodeReferenceOutputChannel | undefined {
|
||||
return this.output.value;
|
||||
}
|
||||
|
||||
private removeChannel() {
|
||||
this.output.value = undefined;
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as assert from 'assert';
|
||||
import { TextEditor } from 'vscode';
|
||||
import { DisposableStore } from '../../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { withInMemoryTelemetry } from '../../../../lib/src/test/telemetry';
|
||||
import { createExtensionTestingContext } from '../../test/context';
|
||||
import { CodeRefEngagementTracker } from '../codeReferenceEngagementTracker';
|
||||
import { citationsChannelName } from '../outputChannel';
|
||||
|
||||
suite('CodeReferenceEngagementTracker', function () {
|
||||
let engagementTracker: CodeRefEngagementTracker;
|
||||
let accessor: ServicesAccessor;
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
setup(function () {
|
||||
accessor = createExtensionTestingContext().createTestingAccessor();
|
||||
engagementTracker = disposables.add(accessor.get(IInstantiationService).createInstance(CodeRefEngagementTracker));
|
||||
});
|
||||
|
||||
teardown(function () {
|
||||
disposables.clear();
|
||||
});
|
||||
|
||||
test('sends a telemetry event when the output channel is focused', async function () {
|
||||
const telemetry = await withInMemoryTelemetry(accessor, () => {
|
||||
engagementTracker.onActiveEditorChange({
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
} as TextEditor);
|
||||
});
|
||||
|
||||
assert.ok(telemetry.reporter.events.length === 1);
|
||||
assert.strictEqual(telemetry.reporter.events[0].name, 'code_referencing.github_copilot_log.focus.count');
|
||||
});
|
||||
|
||||
test('sends a telemetry event when the output channel is focused2', async function () {
|
||||
const telemetry = await withInMemoryTelemetry(accessor, () => {
|
||||
engagementTracker.onActiveEditorChange({
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
} as TextEditor);
|
||||
});
|
||||
|
||||
assert.ok(telemetry.reporter.events.length === 1);
|
||||
assert.strictEqual(telemetry.reporter.events[0].name, 'code_referencing.github_copilot_log.focus.count');
|
||||
});
|
||||
|
||||
|
||||
test('sends a telemetry event when the output channel is opened', async function () {
|
||||
const telemetry = await withInMemoryTelemetry(accessor, () => {
|
||||
engagementTracker.onVisibleEditorsChange([
|
||||
{
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
},
|
||||
] as TextEditor[]);
|
||||
});
|
||||
|
||||
assert.ok(telemetry.reporter.events.length === 1);
|
||||
assert.strictEqual(telemetry.reporter.events[0].name, 'code_referencing.github_copilot_log.open.count');
|
||||
});
|
||||
|
||||
test('does not send a telemetry event when the output channel is already opened', async function () {
|
||||
const telemetry = await withInMemoryTelemetry(accessor, () => {
|
||||
engagementTracker.onVisibleEditorsChange([
|
||||
{
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
},
|
||||
] as TextEditor[]);
|
||||
engagementTracker.onVisibleEditorsChange([
|
||||
{
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
},
|
||||
{
|
||||
document: { uri: { scheme: 'file', path: 'some-other-file.js' } },
|
||||
},
|
||||
] as TextEditor[]);
|
||||
});
|
||||
|
||||
assert.ok(telemetry.reporter.events.length === 1);
|
||||
});
|
||||
|
||||
test('tracks when the log closes internally', async function () {
|
||||
const telemetry = await withInMemoryTelemetry(accessor, () => {
|
||||
engagementTracker.onVisibleEditorsChange([
|
||||
{
|
||||
document: { uri: { scheme: 'output', path: citationsChannelName } },
|
||||
},
|
||||
] as TextEditor[]);
|
||||
engagementTracker.onVisibleEditorsChange([
|
||||
{
|
||||
document: { uri: { scheme: 'file', path: 'some-other-file.js' } },
|
||||
},
|
||||
] as TextEditor[]);
|
||||
});
|
||||
|
||||
assert.ok(telemetry.reporter.events.length === 1);
|
||||
assert.ok(engagementTracker.logVisible === false);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as assert from 'assert';
|
||||
import * as Sinon from 'sinon';
|
||||
import { Disposable, ExtensionContext } from 'vscode';
|
||||
import { CodeReference } from '..';
|
||||
import { CopilotToken, createTestExtendedTokenInfo } from '../../../../../../../platform/authentication/common/copilotToken';
|
||||
import { generateUuid } from '../../../../../../../util/vs/base/common/uuid';
|
||||
import { IInstantiationService } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ConnectionState } from '../../../../lib/src/snippy/connectionState';
|
||||
import { createExtensionTestingContext } from '../../test/context';
|
||||
|
||||
function testExtensionContext() {
|
||||
return {
|
||||
subscriptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
suite('CodeReference', function () {
|
||||
let extensionContext: ExtensionContext;
|
||||
let instantiationService: IInstantiationService;
|
||||
let sub: Disposable | undefined;
|
||||
|
||||
setup(function () {
|
||||
const accessor = createExtensionTestingContext().createTestingAccessor();
|
||||
instantiationService = accessor.get(IInstantiationService);
|
||||
extensionContext = testExtensionContext() as unknown as ExtensionContext;
|
||||
});
|
||||
|
||||
teardown(function () {
|
||||
extensionContext.subscriptions.forEach(sub => {
|
||||
sub.dispose();
|
||||
});
|
||||
sub?.dispose();
|
||||
ConnectionState.setDisabled();
|
||||
});
|
||||
|
||||
suite('subscriptions', function () {
|
||||
test('should be undefined by default', function () {
|
||||
const result = instantiationService.createInstance(CodeReference);
|
||||
sub = result.subscriptions;
|
||||
assert.ok(!sub);
|
||||
});
|
||||
|
||||
test('should be updated correctly when token change events received', function () {
|
||||
const codeQuote = instantiationService.createInstance(CodeReference);
|
||||
const enabledToken = new CopilotToken(createTestExtendedTokenInfo({ token: `test token ${generateUuid()}`, username: 'fixedTokenManager', copilot_plan: 'unknown', code_quote_enabled: true }));
|
||||
const disabledToken = new CopilotToken(createTestExtendedTokenInfo({ token: `test token ${generateUuid()}`, username: 'fixedTokenManager', copilot_plan: 'unknown', code_quote_enabled: false }));
|
||||
|
||||
codeQuote.onCopilotToken(enabledToken);
|
||||
|
||||
assert.ok(codeQuote.enabled);
|
||||
assert.ok(codeQuote.subscriptions);
|
||||
assert.ok(codeQuote.subscriptions instanceof Disposable);
|
||||
|
||||
const subSpy = Sinon.spy(codeQuote.subscriptions, 'dispose');
|
||||
codeQuote.onCopilotToken(disabledToken);
|
||||
|
||||
assert.ok(!codeQuote.enabled);
|
||||
assert.strictEqual(codeQuote.subscriptions, undefined);
|
||||
assert.strictEqual(subSpy.calledOnce, true);
|
||||
|
||||
codeQuote.onCopilotToken(enabledToken);
|
||||
assert.ok(codeQuote.enabled);
|
||||
assert.notStrictEqual(codeQuote.subscriptions, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as assert from 'assert';
|
||||
import sinon from 'sinon';
|
||||
import { commands, env } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../../../../platform/extContext/common/extensionContext';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsNotificationSender } from '../../../../lib/src/notificationSender';
|
||||
import { OutputPaneShowCommand } from '../../../../lib/src/snippy/constants';
|
||||
import { withInMemoryTelemetry } from '../../../../lib/src/test/telemetry';
|
||||
import { TestNotificationSender } from '../../../../lib/src/test/testHelpers';
|
||||
import { createExtensionTestingContext } from '../../test/context';
|
||||
import { notify } from '../matchNotifier';
|
||||
|
||||
suite('.match', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
|
||||
setup(function () {
|
||||
accessor = createExtensionTestingContext().createTestingAccessor();
|
||||
});
|
||||
|
||||
test('populates the globalState object', async function () {
|
||||
const extensionContext = accessor.get(IVSCodeExtensionContext);
|
||||
const globalState = extensionContext.globalState;
|
||||
|
||||
await notify(accessor);
|
||||
|
||||
assert.ok(globalState.get('codeReference.notified'));
|
||||
});
|
||||
|
||||
test('notifies the user', async function () {
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performAction('View reference');
|
||||
|
||||
await notify(accessor);
|
||||
|
||||
assert.strictEqual(testNotificationSender.sentMessages.length, 1);
|
||||
});
|
||||
|
||||
test('sends a telemetry event on view reference action', async function () {
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performAction('View reference');
|
||||
|
||||
const telemetry = await withInMemoryTelemetry(accessor, async accessor => {
|
||||
await notify(accessor);
|
||||
});
|
||||
|
||||
assert.strictEqual(telemetry.reporter.events.length, 1);
|
||||
assert.strictEqual(telemetry.reporter.events[0].name, 'code_referencing.match_notification.acknowledge.count');
|
||||
});
|
||||
|
||||
test('executes the output panel display command on view reference action', async function () {
|
||||
const spy = sinon.spy(commands, 'executeCommand');
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performAction('View reference');
|
||||
|
||||
await notify(accessor);
|
||||
|
||||
await testNotificationSender.waitForMessages();
|
||||
|
||||
assert.ok(spy.calledOnce);
|
||||
assert.ok(spy.calledWith(OutputPaneShowCommand));
|
||||
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('opens the settings page on change setting action', async function () {
|
||||
const stub = sinon.stub(env, 'openExternal');
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performAction('Change setting');
|
||||
|
||||
await notify(accessor);
|
||||
|
||||
await testNotificationSender.waitForMessages();
|
||||
|
||||
assert.ok(stub.calledOnce);
|
||||
assert.ok(
|
||||
stub.calledWith(
|
||||
sinon.match({
|
||||
scheme: 'https',
|
||||
authority: 'aka.ms',
|
||||
path: '/github-copilot-settings',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('sends a telemetry event on notification dismissal', async function () {
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performDismiss();
|
||||
|
||||
const telemetry = await withInMemoryTelemetry(accessor, async accessor => {
|
||||
await notify(accessor);
|
||||
});
|
||||
|
||||
await testNotificationSender.waitForMessages();
|
||||
|
||||
assert.strictEqual(telemetry.reporter.events.length, 1);
|
||||
assert.strictEqual(telemetry.reporter.events[0].name, 'code_referencing.match_notification.ignore.count');
|
||||
});
|
||||
|
||||
test('does not notify if already notified', async function () {
|
||||
const extensionContext = accessor.get(IVSCodeExtensionContext);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const globalState = extensionContext.globalState;
|
||||
const testNotificationSender = accessor.get(ICompletionsNotificationSender) as TestNotificationSender;
|
||||
testNotificationSender.performAction('View reference');
|
||||
|
||||
await globalState.update('codeReference.notified', true);
|
||||
|
||||
await instantiationService.invokeFunction(notify);
|
||||
|
||||
await testNotificationSender.waitForMessages();
|
||||
|
||||
assert.strictEqual(testNotificationSender.sentMessages.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { VSCodeWorkspace } from '../../../../inlineEdits/vscode-node/parts/vscodeWorkspace';
|
||||
import { ICompletionsObservableWorkspace } from '../../lib/src/completionsObservableWorkspace';
|
||||
|
||||
export class CompletionsObservableWorkspace extends VSCodeWorkspace implements ICompletionsObservableWorkspace {
|
||||
declare _serviceBrand: undefined;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { WorkspaceConfiguration } from 'vscode';
|
||||
import * as vscode from 'vscode';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import {
|
||||
ConfigKey,
|
||||
ConfigKeyType,
|
||||
ConfigProvider, getConfigDefaultForKey,
|
||||
getConfigKeyRecursively,
|
||||
getOptionalConfigDefaultForKey,
|
||||
ICompletionsConfigProvider,
|
||||
ICompletionsEditorAndPluginInfo,
|
||||
packageJson
|
||||
} from '../../lib/src/config';
|
||||
import { CopilotConfigPrefix } from '../../lib/src/constants';
|
||||
import { Logger } from '../../lib/src/logger';
|
||||
import { transformEvent } from '../../lib/src/util/event';
|
||||
|
||||
const logger = new Logger('extensionConfig');
|
||||
|
||||
export class VSCodeConfigProvider extends ConfigProvider {
|
||||
private config: WorkspaceConfiguration;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.config = vscode.workspace.getConfiguration(CopilotConfigPrefix);
|
||||
|
||||
// Reload cached config if a workspace config change effects Copilot namespace
|
||||
vscode.workspace.onDidChangeConfiguration(changeEvent => {
|
||||
if (changeEvent.affectsConfiguration(CopilotConfigPrefix)) {
|
||||
this.config = vscode.workspace.getConfiguration(CopilotConfigPrefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override getConfig<T>(key: ConfigKeyType): T {
|
||||
return getConfigKeyRecursively<T>(this.config, key) ?? getConfigDefaultForKey(key);
|
||||
}
|
||||
|
||||
override getOptionalConfig<T>(key: ConfigKeyType): T | undefined {
|
||||
return getConfigKeyRecursively<T>(this.config, key) ?? getOptionalConfigDefaultForKey(key);
|
||||
}
|
||||
|
||||
// Dumps config settings defined in the extension json
|
||||
override dumpForTelemetry(): { [key: string]: string } {
|
||||
return {};
|
||||
}
|
||||
|
||||
override onDidChangeCopilotSettings: ConfigProvider['onDidChangeCopilotSettings'] = transformEvent(
|
||||
vscode.workspace.onDidChangeConfiguration,
|
||||
event => {
|
||||
if (event.affectsConfiguration('github.copilot')) {
|
||||
return this;
|
||||
}
|
||||
if (event.affectsConfiguration('github.copilot-chat')) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// From vscode's src/vs/platform/telemetry/common/telemetryUtils.ts
|
||||
const telemetryAllowedAuthorities = new Set([
|
||||
'ssh-remote',
|
||||
'dev-container',
|
||||
'attached-container',
|
||||
'wsl',
|
||||
'tunnel',
|
||||
'codespaces',
|
||||
'amlext',
|
||||
]);
|
||||
|
||||
export class VSCodeEditorInfo implements ICompletionsEditorAndPluginInfo {
|
||||
declare _serviceBrand: undefined;
|
||||
getEditorInfo() {
|
||||
let devName = vscode.env.uriScheme;
|
||||
if (vscode.version.endsWith('-insider')) {
|
||||
devName = devName.replace(/-insiders$/, '');
|
||||
}
|
||||
const remoteName = vscode.env.remoteName;
|
||||
if (remoteName) {
|
||||
devName += `@${telemetryAllowedAuthorities.has(remoteName) ? remoteName : 'other'}`;
|
||||
}
|
||||
return {
|
||||
name: 'vscode',
|
||||
readableName: vscode.env.appName.replace(/ - Insiders$/, ''),
|
||||
devName: devName,
|
||||
version: vscode.version,
|
||||
root: vscode.env.appRoot,
|
||||
};
|
||||
}
|
||||
getEditorPluginInfo() {
|
||||
return { name: 'copilot-chat', readableName: 'GitHub Copilot for Visual Studio Code', version: packageJson.version };
|
||||
}
|
||||
getRelatedPluginInfo() {
|
||||
// Any additions to this list should also be added as a known filter in
|
||||
// lib/src/experiments/filters.ts
|
||||
return [
|
||||
'ms-vscode.cpptools',
|
||||
'ms-vscode.cmake-tools',
|
||||
'ms-vscode.makefile-tools',
|
||||
'ms-dotnettools.csdevkit',
|
||||
'ms-python.python',
|
||||
'ms-python.vscode-pylance',
|
||||
'vscjava.vscode-java-pack',
|
||||
'vscjava.vscode-java-dependency',
|
||||
'vscode.typescript-language-features',
|
||||
'ms-vscode.vscode-typescript-next',
|
||||
'ms-dotnettools.csharp',
|
||||
'github.copilot-chat',
|
||||
]
|
||||
.map(name => {
|
||||
const extpj = vscode.extensions.getExtension(name)?.packageJSON as unknown;
|
||||
if (extpj && typeof extpj === 'object' && 'version' in extpj && typeof extpj.version === 'string') {
|
||||
return { name, version: extpj.version };
|
||||
}
|
||||
})
|
||||
.filter(plugin => plugin !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
type EnabledConfigKeyType = { [key: string]: boolean };
|
||||
|
||||
function getEnabledConfigObject(accessor: ServicesAccessor): EnabledConfigKeyType {
|
||||
const configProvider = accessor.get(ICompletionsConfigProvider);
|
||||
return { '*': true, ...(configProvider.getConfig<EnabledConfigKeyType>(ConfigKey.Enable) ?? {}) };
|
||||
}
|
||||
|
||||
function getEnabledConfig(accessor: ServicesAccessor, languageId: string): boolean {
|
||||
const obj = getEnabledConfigObject(accessor);
|
||||
return obj[languageId] ?? obj['*'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if automatic completions are enabled for the current document by all Copilot completion settings.
|
||||
* Excludes the `editor.inlineSuggest.enabled` setting.
|
||||
* Return undefined if there is no current document.
|
||||
*/
|
||||
export function isCompletionEnabled(accessor: ServicesAccessor): boolean | undefined {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
return isCompletionEnabledForDocument(accessor, editor.document);
|
||||
}
|
||||
|
||||
export function isCompletionEnabledForDocument(accessor: ServicesAccessor, document: vscode.TextDocument): boolean {
|
||||
return getEnabledConfig(accessor, document.languageId);
|
||||
}
|
||||
|
||||
export function isInlineSuggestEnabled(): boolean | undefined {
|
||||
return vscode.workspace.getConfiguration('editor.inlineSuggest').get<boolean>('enabled');
|
||||
}
|
||||
|
||||
type ConfigurationInspect = Exclude<ReturnType<vscode.WorkspaceConfiguration['inspect']>, undefined>;
|
||||
const inspectKinds: [keyof ConfigurationInspect, vscode.ConfigurationTarget, boolean][] = [
|
||||
['workspaceFolderLanguageValue', vscode.ConfigurationTarget.WorkspaceFolder, true],
|
||||
['workspaceFolderValue', vscode.ConfigurationTarget.WorkspaceFolder, false],
|
||||
['workspaceLanguageValue', vscode.ConfigurationTarget.Workspace, true],
|
||||
['workspaceValue', vscode.ConfigurationTarget.Workspace, false],
|
||||
['globalLanguageValue', vscode.ConfigurationTarget.Global, true],
|
||||
['globalValue', vscode.ConfigurationTarget.Global, false],
|
||||
];
|
||||
|
||||
function getConfigurationTargetForEnabledConfig(): vscode.ConfigurationTarget {
|
||||
const inspect = vscode.workspace.getConfiguration(CopilotConfigPrefix).inspect(ConfigKey.Enable);
|
||||
if (inspect?.workspaceFolderValue !== undefined) {
|
||||
return vscode.ConfigurationTarget.WorkspaceFolder;
|
||||
} else if (inspect?.workspaceValue !== undefined) {
|
||||
return vscode.ConfigurationTarget.Workspace;
|
||||
} else {
|
||||
return vscode.ConfigurationTarget.Global;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable completions by every means possible.
|
||||
*/
|
||||
export async function enableCompletions(accessor: ServicesAccessor) {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const scope = vscode.window.activeTextEditor?.document;
|
||||
// Make sure both of these settings are enabled, because that's a precondition for the user seeing inline completions.
|
||||
for (const [section, option] of [['', 'editor.inlineSuggest.enabled']]) {
|
||||
const config = vscode.workspace.getConfiguration(section, scope);
|
||||
const inspect = config.inspect(option);
|
||||
// Start from the most specific setting and work our way up to the global default.
|
||||
for (const [key, target, overrideInLanguage] of inspectKinds) {
|
||||
// Exit condition: if VS Code thinks the setting is enabled, we're done.
|
||||
// This might be true from the start, or a call to .update() might flip it.
|
||||
if (vscode.workspace.getConfiguration(section, scope).get(option)) {
|
||||
break;
|
||||
}
|
||||
if (inspect?.[key] === false) {
|
||||
await config.update(option, true, target, overrideInLanguage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rest of this function is the inverse of disableCompletions(), updating the github.copilot.enable setting.
|
||||
const languageId = vscode.window.activeTextEditor?.document.languageId;
|
||||
if (!languageId) { return; }
|
||||
const config = vscode.workspace.getConfiguration(CopilotConfigPrefix);
|
||||
const enabledConfig = { ...instantiationService.invokeFunction(getEnabledConfigObject) };
|
||||
if (!(languageId in enabledConfig)) {
|
||||
enabledConfig['*'] = true;
|
||||
} else {
|
||||
enabledConfig[languageId] = true;
|
||||
}
|
||||
await config.update(ConfigKey.Enable, enabledConfig, getConfigurationTargetForEnabledConfig());
|
||||
if (!instantiationService.invokeFunction(isCompletionEnabled)) {
|
||||
const inspect = vscode.workspace.getConfiguration(CopilotConfigPrefix).inspect(ConfigKey.Enable);
|
||||
const error = new Error(`Failed to enable completions for ${languageId}: ${JSON.stringify(inspect)}`);
|
||||
instantiationService.invokeFunction(acc => logger.exception(acc, error, '.enable'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable completions using the github.copilot.enable setting.
|
||||
*/
|
||||
export async function disableCompletions(accessor: ServicesAccessor) {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const languageId = vscode.window.activeTextEditor?.document.languageId;
|
||||
if (!languageId) { return; }
|
||||
const config = vscode.workspace.getConfiguration(CopilotConfigPrefix);
|
||||
const enabledConfig = { ...instantiationService.invokeFunction(getEnabledConfigObject) };
|
||||
if (!(languageId in enabledConfig)) {
|
||||
enabledConfig['*'] = false;
|
||||
} else if (enabledConfig[languageId]) {
|
||||
enabledConfig[languageId] = false;
|
||||
}
|
||||
await config.update(ConfigKey.Enable, enabledConfig, getConfigurationTargetForEnabledConfig());
|
||||
if (instantiationService.invokeFunction(isCompletionEnabled)) {
|
||||
const inspect = vscode.workspace.getConfiguration(CopilotConfigPrefix).inspect(ConfigKey.Enable);
|
||||
const error = new Error(`Failed to disable completions for ${languageId}: ${JSON.stringify(inspect)}`);
|
||||
instantiationService.invokeFunction(acc => logger.exception(acc, error, '.disable'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleCompletions(accessor: ServicesAccessor) {
|
||||
if (isCompletionEnabled(accessor) && isInlineSuggestEnabled()) {
|
||||
await disableCompletions(accessor);
|
||||
} else {
|
||||
await enableCompletions(accessor);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// Commands ending with "Client" refer to the command ID used in the legacy Copilot extension.
|
||||
// - These IDs should not appear in the package.json file
|
||||
// - These IDs should be registered to support all functionality (except if this command needs to be supported when both extensions are loaded/active).
|
||||
// Commands ending with "Chat" refer to the command ID used in the Copilot Chat extension.
|
||||
// - These IDs should be used in package.json
|
||||
// - These IDs should only be registered if they appear in the package.json (meaning the command palette) or if the command needs to be supported when both extensions are loaded/active.
|
||||
|
||||
export const CMDOpenPanelClient = 'github.copilot.generate';
|
||||
export const CMDOpenPanelChat = 'github.copilot.chat.openSuggestionsPanel'; // "github.copilot.chat.generate" is already being used
|
||||
|
||||
export const CMDAcceptCursorPanelSolutionClient = 'github.copilot.acceptCursorPanelSolution';
|
||||
export const CMDNavigatePreviousPanelSolutionClient = 'github.copilot.previousPanelSolution';
|
||||
export const CMDNavigateNextPanelSolutionClient = 'github.copilot.nextPanelSolution';
|
||||
|
||||
export const CMDToggleStatusMenuClient = 'github.copilot.toggleStatusMenu';
|
||||
export const CMDToggleStatusMenuChat = 'github.copilot.chat.toggleStatusMenu';
|
||||
|
||||
// Needs to be supported in both extensions when they are loaded/active. Requires a different ID.
|
||||
export const CMDSendCompletionsFeedbackChat = 'github.copilot.chat.sendCompletionFeedback';
|
||||
|
||||
export const CMDEnableCompletionsChat = 'github.copilot.chat.completions.enable';
|
||||
export const CMDDisableCompletionsChat = 'github.copilot.chat.completions.disable';
|
||||
export const CMDToggleCompletionsChat = 'github.copilot.chat.completions.toggle';
|
||||
export const CMDEnableCompletionsClient = 'github.copilot.completions.enable';
|
||||
export const CMDDisableCompletionsClient = 'github.copilot.completions.disable';
|
||||
export const CMDToggleCompletionsClient = 'github.copilot.completions.toggle';
|
||||
|
||||
export const CMDOpenLogsClient = 'github.copilot.openLogs';
|
||||
export const CMDOpenDocumentationClient = 'github.copilot.openDocs';
|
||||
|
||||
// Existing chat command reused for diagnostics
|
||||
export const CMDCollectDiagnosticsChat = 'github.copilot.debug.collectDiagnostics';
|
||||
|
||||
// Context variable that enable/disable panel-specific commands
|
||||
export const CopilotPanelVisible = 'github.copilot.panelVisible';
|
||||
export const ComparisonPanelVisible = 'github.copilot.comparisonPanelVisible';
|
||||
|
||||
export const CMDOpenModelPickerClient = 'github.copilot.openModelPicker';
|
||||
export const CMDOpenModelPickerChat = 'github.copilot.chat.openModelPicker';
|
||||
@@ -1,28 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { languages, workspace } from 'vscode';
|
||||
import { DocumentSelector } from 'vscode-languageserver-protocol';
|
||||
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { isDocumentValid } from '../../lib/src/util/documentEvaluation';
|
||||
import { DocumentContext } from '../../types/src';
|
||||
|
||||
export async function contextProviderMatch(
|
||||
instantiationService: IInstantiationService,
|
||||
documentSelector: DocumentSelector,
|
||||
documentContext: DocumentContext
|
||||
): Promise<number> {
|
||||
const vscDoc = workspace.textDocuments.find(td => td.uri.toString() === documentContext.uri);
|
||||
if (!vscDoc) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await instantiationService.invokeFunction(isDocumentValid, documentContext);
|
||||
if (result.status !== 'valid') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return languages.match(documentSelector, vscDoc);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Command, commands, InlineCompletionItem, Uri } from 'vscode';
|
||||
import { Disposable } from '../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { collectCompletionDiagnostics, formatDiagnosticsAsMarkdown } from '../../lib/src/diagnostics';
|
||||
import { telemetry, TelemetryData } from '../../lib/src/telemetry';
|
||||
import { CMDSendCompletionsFeedbackChat } from './constants';
|
||||
|
||||
export const sendCompletionFeedbackCommand: Command = {
|
||||
command: CMDSendCompletionsFeedbackChat,
|
||||
title: 'Send Copilot Completion Feedback',
|
||||
tooltip: 'Send feedback about the last shown Copilot completion item',
|
||||
};
|
||||
|
||||
export class CopilotCompletionFeedbackTracker extends Disposable {
|
||||
private lastShownCopilotCompletionItem: InlineCompletionItem | undefined;
|
||||
|
||||
constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) {
|
||||
super();
|
||||
this._register(commands.registerCommand(sendCompletionFeedbackCommand.command, async () => {
|
||||
const commandArg: unknown = this.lastShownCopilotCompletionItem?.command?.arguments?.[0];
|
||||
let telemetryArg: TelemetryData | undefined;
|
||||
if (commandArg && typeof commandArg === 'object' && 'telemetry' in commandArg) {
|
||||
if (commandArg.telemetry instanceof TelemetryData) {
|
||||
telemetryArg = commandArg.telemetry;
|
||||
}
|
||||
}
|
||||
this.instantiationService.invokeFunction(telemetry, 'ghostText.sentFeedback', telemetryArg);
|
||||
|
||||
await this.instantiationService.invokeFunction(openGitHubIssue, this.lastShownCopilotCompletionItem, telemetryArg);
|
||||
}));
|
||||
}
|
||||
|
||||
trackItem(item: InlineCompletionItem) {
|
||||
this.lastShownCopilotCompletionItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
async function openGitHubIssue(
|
||||
accessor: ServicesAccessor,
|
||||
item: InlineCompletionItem | undefined,
|
||||
telemetry: TelemetryData | undefined
|
||||
) {
|
||||
const body = generateGitHubIssueBody(accessor, item, telemetry);
|
||||
await commands.executeCommand('workbench.action.openIssueReporter', {
|
||||
extensionId: 'github.copilot',
|
||||
uri: Uri.parse('https://github.com/microsoft/vscode'),
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
function generateGitHubIssueBody(
|
||||
accessor: ServicesAccessor,
|
||||
item: InlineCompletionItem | undefined,
|
||||
telemetry: TelemetryData | undefined
|
||||
) {
|
||||
const diagnostics = collectCompletionDiagnostics(accessor, telemetry);
|
||||
const formattedDiagnostics = formatDiagnosticsAsMarkdown(diagnostics);
|
||||
if (typeof item?.insertText !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `## Copilot Completion Feedback
|
||||
### Describe the issue, feedback, or steps to reproduce it:
|
||||
|
||||
|
||||
### Completion text:
|
||||
\`\`\`
|
||||
${item.insertText}
|
||||
\`\`\`
|
||||
|
||||
<details>
|
||||
<summary>Diagnostics</summary>
|
||||
|
||||
${formattedDiagnostics}
|
||||
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Range, commands, window, type Disposable } from 'vscode';
|
||||
import { CopilotNamedAnnotationList } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { DisposableStore, IDisposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService, type ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import * as constants from '../constants';
|
||||
import { registerCommand } from '../telemetry';
|
||||
import { wrapDoc } from '../textDocumentManager';
|
||||
import { CopilotSuggestionsPanelManager } from './copilotSuggestionsPanelManager';
|
||||
|
||||
// Exported for testing
|
||||
export enum PanelNavigationType {
|
||||
Previous = 'previous',
|
||||
Next = 'next',
|
||||
}
|
||||
|
||||
/**
|
||||
* This interface contains data associated to a completion displayed in the panel.
|
||||
*/
|
||||
export interface PanelCompletion {
|
||||
insertText: string;
|
||||
range: Range;
|
||||
copilotAnnotations?: CopilotNamedAnnotationList;
|
||||
postInsertionCallback: () => PromiseLike<void> | void;
|
||||
}
|
||||
|
||||
export function registerPanelSupport(accessor: ServicesAccessor): Disposable {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const suggestionsPanelManager = instantiationService.createInstance(CopilotSuggestionsPanelManager);
|
||||
|
||||
const disposableStore = new DisposableStore();
|
||||
|
||||
function registerOpenPanelCommand(id: string): IDisposable {
|
||||
return registerCommand(accessor, id, async () => {
|
||||
// hide ghost text while opening the generation ui
|
||||
await commands.executeCommand('editor.action.inlineSuggest.hide');
|
||||
await instantiationService.invokeFunction(commandOpenPanel, suggestionsPanelManager);
|
||||
});
|
||||
}
|
||||
|
||||
// Register both commands to also support command palette
|
||||
disposableStore.add(registerOpenPanelCommand(constants.CMDOpenPanelChat));
|
||||
disposableStore.add(registerOpenPanelCommand(constants.CMDOpenPanelClient));
|
||||
|
||||
// No command palette support needed for these commands
|
||||
disposableStore.add(suggestionsPanelManager.registerCommands());
|
||||
|
||||
return disposableStore;
|
||||
}
|
||||
|
||||
function commandOpenPanel(accessor: ServicesAccessor, suggestionsPanelManager: CopilotSuggestionsPanelManager) {
|
||||
const editor = window.activeTextEditor;
|
||||
if (!editor) { return; }
|
||||
const wrapped = wrapDoc(editor.document);
|
||||
if (!wrapped) { return; }
|
||||
|
||||
const { line, character } = editor.selection.active;
|
||||
|
||||
suggestionsPanelManager.renderPanel(editor.document, { line, character }, wrapped);
|
||||
return commands.executeCommand('setContext', constants.CopilotPanelVisible, true);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { IPosition, ITextDocument } from '../../../lib/src/textDocument';
|
||||
import { solutionCountTarget } from '../lib/copilotPanel/common';
|
||||
import { runSolutions } from '../lib/copilotPanel/panel';
|
||||
import { UnformattedSolution } from '../lib/panelShared/panelTypes';
|
||||
import { BaseListDocument } from '../panelShared/baseListDocument';
|
||||
import { BasePanelCompletion, ISuggestionsPanel } from '../panelShared/basePanelTypes';
|
||||
import { PanelCompletion } from './common';
|
||||
|
||||
/**
|
||||
* Class representing a Open Copilot list using a ITextDocument as a way of displaying results.
|
||||
* Currently only used in the VSCode extension.
|
||||
*/
|
||||
export class CopilotListDocument extends BaseListDocument<PanelCompletion> {
|
||||
constructor(
|
||||
textDocument: ITextDocument,
|
||||
position: IPosition,
|
||||
panel: ISuggestionsPanel,
|
||||
countTarget = solutionCountTarget,
|
||||
@IInstantiationService instantiationService: IInstantiationService
|
||||
) {
|
||||
super(textDocument, position, panel, countTarget, instantiationService);
|
||||
}
|
||||
|
||||
protected createPanelCompletion(
|
||||
unformatted: UnformattedSolution,
|
||||
baseCompletion: BasePanelCompletion
|
||||
): PanelCompletion {
|
||||
return {
|
||||
insertText: baseCompletion.insertText,
|
||||
range: baseCompletion.range,
|
||||
copilotAnnotations: baseCompletion.copilotAnnotations,
|
||||
postInsertionCallback: baseCompletion.postInsertionCallback,
|
||||
};
|
||||
}
|
||||
|
||||
protected shouldAddSolution(newItem: PanelCompletion): boolean {
|
||||
return !this.findDuplicateSolution(newItem);
|
||||
}
|
||||
|
||||
protected runSolutionsImpl(): Promise<void> {
|
||||
return this.instantiationService.invokeFunction(runSolutions, this, this);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { TextDocument, WebviewPanel } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../../../platform/extContext/common/extensionContext';
|
||||
import { BaseSuggestionsPanel, SolutionContent, WebviewMessage } from '../panelShared/baseSuggestionsPanel';
|
||||
import { PanelCompletion } from './common';
|
||||
import { CopilotSuggestionsPanelManager } from './copilotSuggestionsPanelManager';
|
||||
import { copilotPanelConfig } from './panelConfig';
|
||||
|
||||
export interface CopilotSolutionsMessage {
|
||||
command: 'solutionsUpdated';
|
||||
solutions: SolutionContent[];
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class CopilotSuggestionsPanel extends BaseSuggestionsPanel<PanelCompletion> {
|
||||
constructor(
|
||||
webviewPanel: WebviewPanel,
|
||||
document: TextDocument,
|
||||
suggestionsPanelManager: CopilotSuggestionsPanelManager,
|
||||
@IVSCodeExtensionContext contextService: IVSCodeExtensionContext,
|
||||
) {
|
||||
super(webviewPanel, document, suggestionsPanelManager, copilotPanelConfig, contextService);
|
||||
}
|
||||
|
||||
protected renderSolutionContent(item: PanelCompletion, baseContent: SolutionContent): SolutionContent {
|
||||
// Copilot panel just returns the base content without modifications
|
||||
return baseContent;
|
||||
}
|
||||
|
||||
protected createSolutionsMessage(content: SolutionContent[], percentage: number): CopilotSolutionsMessage {
|
||||
return {
|
||||
command: 'solutionsUpdated',
|
||||
solutions: content,
|
||||
percentage,
|
||||
};
|
||||
}
|
||||
|
||||
protected override async handleCustomMessage(message: WebviewMessage): Promise<boolean> {
|
||||
switch (message.command) {
|
||||
case 'acceptSolution': {
|
||||
const solution = this.items()[message.solutionIndex];
|
||||
await this.acceptSolution(solution, true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
default:
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { TextDocument, WebviewPanel } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../../../platform/extContext/common/extensionContext';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { IPosition, ITextDocument } from '../../../lib/src/textDocument';
|
||||
import { solutionCountTarget } from '../lib/copilotPanel/common';
|
||||
import { BaseSuggestionsPanelManager, ListDocumentInterface } from '../panelShared/baseSuggestionsPanelManager';
|
||||
import { PanelCompletion } from './common';
|
||||
import { CopilotListDocument } from './copilotListDocument';
|
||||
import { CopilotSuggestionsPanel } from './copilotSuggestionsPanel';
|
||||
import { copilotPanelConfig } from './panelConfig';
|
||||
|
||||
export class CopilotSuggestionsPanelManager extends BaseSuggestionsPanelManager<PanelCompletion> {
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext,
|
||||
) {
|
||||
super(copilotPanelConfig, instantiationService, extensionContext);
|
||||
}
|
||||
|
||||
protected createListDocument(
|
||||
wrapped: ITextDocument,
|
||||
position: IPosition,
|
||||
panel: CopilotSuggestionsPanel
|
||||
): ListDocumentInterface {
|
||||
return this._instantiationService.createInstance(CopilotListDocument, wrapped, position, panel, solutionCountTarget);
|
||||
}
|
||||
|
||||
protected createSuggestionsPanel(
|
||||
panel: WebviewPanel,
|
||||
document: TextDocument,
|
||||
manager: this
|
||||
): CopilotSuggestionsPanel {
|
||||
return this._instantiationService.createInstance(CopilotSuggestionsPanel, panel, document, manager);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as constants from '../constants';
|
||||
import { CopilotPanelVisible } from '../constants';
|
||||
import { PanelConfig } from '../panelShared/basePanelTypes';
|
||||
|
||||
// Configuration for the GitHub Copilot Suggestions Panel
|
||||
export const copilotPanelConfig: PanelConfig = {
|
||||
panelTitle: 'GitHub Copilot Suggestions',
|
||||
webviewId: 'GitHub Copilot Suggestions',
|
||||
webviewScriptName: 'suggestionsPanelWebview.js',
|
||||
contextVariable: CopilotPanelVisible,
|
||||
commands: {
|
||||
accept: constants.CMDAcceptCursorPanelSolutionClient,
|
||||
navigatePrevious: constants.CMDNavigatePreviousPanelSolutionClient,
|
||||
navigateNext: constants.CMDNavigateNextPanelSolutionClient,
|
||||
},
|
||||
renderingMode: 'streaming',
|
||||
shuffleSolutions: false,
|
||||
};
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { provideVSCodeDesignSystem, vsCodeButton } from '@vscode/webview-ui-toolkit';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const solutionsContainer = document.getElementById('solutionsContainer');
|
||||
const vscode = acquireVsCodeApi();
|
||||
let currentFocusIndex: number = 0;
|
||||
let solutionEventHandlersInitialized = false;
|
||||
|
||||
provideVSCodeDesignSystem().register(vsCodeButton());
|
||||
|
||||
type Message = {
|
||||
command: string;
|
||||
solutions: {
|
||||
htmlSnippet: string;
|
||||
citation?: {
|
||||
message: string;
|
||||
url: string;
|
||||
};
|
||||
}[];
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
// Notify the extension that the webview is ready
|
||||
vscode.postMessage({ command: 'webviewReady' });
|
||||
initializeSolutionEventHandlers();
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const message = event.data as Message; // The JSON data our extension sent
|
||||
|
||||
switch (message.command) {
|
||||
case 'solutionsUpdated':
|
||||
handleSolutionUpdate(message);
|
||||
break;
|
||||
case 'navigatePreviousSolution':
|
||||
navigatePreviousSolution();
|
||||
break;
|
||||
case 'navigateNextSolution':
|
||||
navigateNextSolution();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSolutionUpdate(message: Message) {
|
||||
updateLoadingContainer(message);
|
||||
|
||||
if (solutionsContainer) {
|
||||
solutionsContainer.innerHTML = message.solutions
|
||||
.map((solution, index) => {
|
||||
const renderedCitation = solution.citation
|
||||
? `<p>
|
||||
<span style="vertical-align: text-bottom" aria-hidden="true">Warning</span>
|
||||
${DOMPurify.sanitize(solution.citation.message)}
|
||||
<a href="${DOMPurify.sanitize(solution.citation.url)}" target="_blank">Inspect source code</a>
|
||||
</p>`
|
||||
: '';
|
||||
const sanitizedSnippet = DOMPurify.sanitize(solution.htmlSnippet);
|
||||
|
||||
return `<h3 class='solutionHeading' id="solution-${index + 1}-heading">Suggestion ${index + 1}</h3>
|
||||
<div class='snippetContainer' aria-labelledby="solution-${index + 1}-heading" role="group" data-solution-index="${index}">${sanitizedSnippet
|
||||
}</div>
|
||||
${DOMPurify.sanitize(renderedCitation)}
|
||||
<vscode-button role="button" class="acceptButton" id="acceptButton${index}" appearance="secondary" data-solution-index="${index}">Accept suggestion ${index + 1
|
||||
}</vscode-button>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function navigatePreviousSolution() {
|
||||
const snippets = document.querySelectorAll<HTMLElement>('.snippetContainer pre');
|
||||
const prevIndex = currentFocusIndex - 1;
|
||||
|
||||
snippets[prevIndex]?.focus();
|
||||
}
|
||||
|
||||
function navigateNextSolution() {
|
||||
const snippets = document.querySelectorAll<HTMLElement>('.snippetContainer pre');
|
||||
const nextIndex = (currentFocusIndex ?? -1) + 1;
|
||||
|
||||
if (snippets[nextIndex]) {
|
||||
snippets[nextIndex].focus();
|
||||
} else if (snippets[0]) {
|
||||
snippets[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadingContainer(message: Message) {
|
||||
const progressBar = document.getElementById('progress-bar') as HTMLProgressElement;
|
||||
const loadingContainer = document.getElementById('loadingContainer') as HTMLDivElement;
|
||||
if (!progressBar || !loadingContainer) {
|
||||
return;
|
||||
}
|
||||
if (message.percentage >= 100) {
|
||||
loadingContainer.innerHTML = `${message.solutions.length} Suggestions`;
|
||||
} else {
|
||||
const loadingLabelElement = loadingContainer.querySelector('label') as HTMLLabelElement;
|
||||
if (loadingLabelElement.textContent !== 'Loading suggestions:\u00A0') {
|
||||
loadingLabelElement.textContent = 'Loading suggestions:\u00A0';
|
||||
}
|
||||
progressBar.value = message.percentage;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function initializeSolutionEventHandlers(): void {
|
||||
if (solutionEventHandlersInitialized || solutionsContainer === null) {
|
||||
return;
|
||||
}
|
||||
solutionsContainer.addEventListener('focusin', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const index = extractSolutionIndex(target);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
handleFocus(index);
|
||||
});
|
||||
solutionsContainer.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const button = target?.closest('vscode-button[data-solution-index]');
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const index = extractSolutionIndex(button);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
handleClick(index);
|
||||
});
|
||||
solutionEventHandlersInitialized = true;
|
||||
}
|
||||
|
||||
function extractSolutionIndex(element: HTMLElement | null): number | undefined {
|
||||
const solutionElement = element?.closest('[data-solution-index]');
|
||||
if (!(solutionElement instanceof HTMLElement)) {
|
||||
return undefined;
|
||||
}
|
||||
const attributeValue = solutionElement.getAttribute('data-solution-index');
|
||||
if (attributeValue === null) {
|
||||
return undefined;
|
||||
}
|
||||
const index = Number.parseInt(attributeValue, 10);
|
||||
return Number.isNaN(index) ? undefined : index;
|
||||
}
|
||||
|
||||
function handleFocus(index: number) {
|
||||
currentFocusIndex = index;
|
||||
vscode.postMessage({
|
||||
command: 'focusSolution',
|
||||
solutionIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
function handleClick(index: number) {
|
||||
vscode.postMessage({
|
||||
command: 'acceptSolution',
|
||||
solutionIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2022",
|
||||
"skipLibCheck": true, // https://github.com/DataDog/datadog-ci/issues/1059
|
||||
"sourceMap": true,
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2021", "dom"],
|
||||
// Reset values set in the parent tsconfig
|
||||
"strict": true, /* enable all strict type-checking options */
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noImplicitOverride": true, /* Force use of `override` keyword. */
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": false,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"isolatedModules": false,
|
||||
},
|
||||
"exclude": [],
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { Command, StatusKind } from '../../types/src';
|
||||
|
||||
export const ICompletionsExtensionStatus = createServiceIdentifier<ICompletionsExtensionStatus>('ICompletionsExtensionStatus');
|
||||
export interface ICompletionsExtensionStatus {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
kind: StatusKind;
|
||||
message?: string;
|
||||
busy: boolean;
|
||||
command?: Command;
|
||||
}
|
||||
|
||||
export class CopilotExtensionStatus implements ICompletionsExtensionStatus {
|
||||
declare _serviceBrand: undefined;
|
||||
constructor(
|
||||
public kind: StatusKind = 'Normal',
|
||||
public message?: string,
|
||||
public busy = false,
|
||||
public command?: Command
|
||||
) { }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user