Compare commits

..

6 Commits

Author SHA1 Message Date
ydy0615 05b1cbf80d chore: 更新项目配置和前后端代码优化 2026-04-04 20:10:24 +08:00
ydy0615 ef162de168 modified: backend/requirements.txt 2026-03-14 20:48:44 +08:00
ydy0615 1155de4867 feat(MilkdownEditor): add file upload support for documents and text files
Added new upload functionality to the editor supporting doc/docx/ppt/pptx/pdf/zip/txt/json files. Includes:
- New upload button with file input
- File type detection utilities (isTextFile, isConvertibleFile)
- Initial markdown sync with trailing whitespace normalization
- Warning messages for unsupported file types
2026-03-14 19:24:15 +08:00
ydy0615 d452d1747e feat: add language synonym mapping and canonicalization
Add LANGUAGE_SYNONYMS dictionary to map language aliases to canonical IDs,
_canonical_language_id() to normalize language identifiers, and
_language_guidance() to provide language-specific instructions for LLM
code generation. This improves language detection and ensures consistent
prompt context across different language format variations.
2026-03-14 18:20:39 +08:00
ydy0615 c0d4bf8b2b chore: remove unnecessary comments and suppress error logging 2026-03-10 23:26:18 +08:00
ydy0615 8d89c2a0f6 Merge remote changes with local modifications
- Add docx and html2pdf.js export functionality (from remote)
- Update backend with new API endpoints
- Sync local configuration changes
2026-03-10 23:10:11 +08:00
24 changed files with 4056 additions and 1595 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"shortcuts": [
{
"label": "Run",
"command": "npm run dev",
"icon": "play"
}
]
}
+4 -3
View File
@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://149.104.29.239:8001
VITE_API_URL=http://149.104.29.239:8001/v1/completions
VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr
VITE_API_BASE_URL=
VITE_API_URL=
VITE_OCR_URL=
VITE_CONVERT_URL=
+52
View File
@@ -0,0 +1,52 @@
# 仓库指南
## 语言约定
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
## 项目结构 \& 模块组织
```
backend/ # FastAPI 后端(Python
├─ main.py # API 入口
├─ llm.py # LLM 包装工具
├─ prompt.py # Prompt 构建辅助
└─ tests/ # pytest 测试套件
public/ # 前端静态资源
src/ # 前端源码(Vite + React
dist/ # 构建产出(生成文件)
```
生产代码主要位于 `backend/`Python)和 `src/`(JS/TS)。测试文件与被测模块并置。
## 构建、测试、开发命令
| 命令 | 说明 |
|----------------------------------------------|--------------------------------------------------|
| `npm install` | 安装前端依赖 |
| `npm run dev` | 启动 Vite 开发服务器 |
| `uvicorn backend.main:app --reload` | 本地运行 FastAPI 服务 |
| `pytest` | 运行 Python 测试套件 |
| `npm run build` | 生成生产环境构建产物至 `dist/` |
## 编码风格 \& 命名约定
- **Python**:使用 4 空格缩进,`snake_case` 命名函数/变量,`PascalCase` 命名类。提交前请使用 `ruff`/`black` 格式化。
- **JavaScript/TypeScript**:使用 2 空格缩进,`camelCase` 命名变量/函数,`PascalCase` 命名 React 组件。使用 `eslint``prettier` 检查。
- 文件名采用全小写加短横线,例如 `my-module.py``my-component.tsx`
## 测试指南
- 后端使用 **pytest**,测试文件放在对应模块目录下,命名为 `test_<module>.py`
- 目标覆盖率 ≥ 80%`pytest --cov=backend`)。
- 在虚拟环境中运行:`pip install -r backend/requirements.txt && pytest`
## 提交 \& Pull Request 规范
- 提交信息遵循 **Conventional Commits**`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
- PR 必须包含:
- 与提交信息匹配的标题。
- 关联的 Issue(如 `Fixes #123`)。
- UI 变更或 API 示例的截图/示例。
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
## 安全 \& 配置建议
- 敏感信息请放入 `.env` 并确保已在 `.gitignore` 中。
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
- 定期审计依赖安全(`npm audit``pip-audit`)。
---
以上指南旨在保持贡献一致性并维护代码库健康,欢迎通过 Pull Request 提出改进。
+814
View File
@@ -0,0 +1,814 @@
# llm-in-text 修复清单(匿名可用版)
## 说明
这不是审计报告。
这份文档只回答三件事:
1. 现在具体哪里有问题
2. 问题为什么会发生
3. 应该怎么改
前提按你的要求处理:
- 网站是匿名可用的
- 不做用户登录
- 不做用户身份体系
- 但仍然要防止接口被滥用、站点被刷爆、服务被恶意调用
匿名可用不等于完全不做保护。
对于这种网站,正确做法通常是:
- 不做用户登录
- 不在前端放任何真正的服务端秘密
- 用服务端限流、来源限制、请求大小限制、网关策略保护接口
- 必要时用站点级防刷手段,而不是用户级登录
---
## 1. 前端硬编码了服务端 API Key
### 具体问题
- [src/utils/api.js:4](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L4)
- [src/utils/convert.js:3](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js#L3)
代码里把:
```js
const API_KEY = 'your-secret-key-here'
```
直接写进了前端源码。
### 错误原因
前端代码最终会发到浏览器里。
只要用户能打开网站,就一定能在浏览器开发者工具、打包产物、网络请求里看到这个 key。
所以前端里的“密钥”根本不是密钥,只是公开字符串。
### 会导致什么
- 任何人都可以绕过你的网站,直接写脚本刷你的后端
- 这个 key 一旦被复制,就等于后端公开可调用
### 整改方式
你的场景不做登录,所以最简单、正确的做法是:
1. 删除前端里的 `API_KEY`
2. 后端不要再要求前端传固定共享 key
3. 改成下面这套匿名保护方案:
- 只允许来自你站点域名的浏览器请求
- 网关层限流
- 接口级限流
- 请求体大小限制
- 必要时加站点级验证码或 challenge,而不是登录
### 你应该改成什么
- `src/utils/api.js` 不再发 `X-API-Key`
- `src/utils/convert.js` 不再发 `X-API-Key`
- `backend/main.py` 删除固定 `API_KEY` 和对应校验逻辑
### 验收标准
- 全仓库搜不到 `your-secret-key-here`
- 前端请求头中不再包含固定共享 key
---
## 2. 后端 CORS 过宽
### 具体问题
- [backend/main.py:34](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L34)
- [backend/main.py:35](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L35)
- [backend/main.py:36](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L36)
- [backend/main.py:37](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L37)
现在配置是:
- `allow_origins=["*"]`
- `allow_credentials=True`
- `allow_methods=["*"]`
- `allow_headers=["*"...]`
### 错误原因
这是开发期常见的“先全开让它跑起来”的写法。
但生产里这样做会让任何站点都能更容易发起跨域调用。
### 会导致什么
- 其他网站更容易借你的浏览器接口能力
- 以后一旦加 cookie、session、任何凭据,会立刻放大风险
### 整改方式
既然你是匿名站点,不做登录,那就更应该把跨域收紧:
1. 只允许你的正式域名和本地开发域名
2. 不要开 `allow_credentials=True`,匿名站一般不需要
3. 只开放需要的方法和头
### 建议改法
把:
```python
allow_origins=["*"]
allow_credentials=True
allow_methods=["*"]
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"]
```
改成类似:
```python
allow_origins=[
"https://your-domain.com",
"https://www.your-domain.com",
"http://localhost:5173",
]
allow_credentials=False
allow_methods=["POST", "OPTIONS"]
allow_headers=["Content-Type", "X-Request-Id"]
```
### 验收标准
- 非你自己域名的网页无法直接跨域调用你的接口
- 不再开放无用头和无用方法
---
## 3. 默认会去拿用户公网 IP,并发送给后端
### 具体问题
- [src/stores/settings.js:16](/C:/Users/ydy/Desktop/llm-in-text/src/stores/settings.js#L16)
- [src/utils/api.js:54](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L54)
- [src/utils/api.js:100](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L100)
- [backend/main.py:110](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L110)
流程是:
1. 前端默认 `privacyMode = false`
2. 前端请求 `https://api.ipify.org?format=json`
3. 获取公网 IP
4. 放进 `X-Client-IP`
5. 后端再做地理位置推断
### 错误原因
这是把“个性化上下文”做成了默认行为。
但对匿名站点来说,这不是必要信息。
### 会导致什么
- 页面会额外访问第三方服务
- 用户 IP 会进入你的请求链路
- 模型上下文中会混入地理位置信息
### 整改方式
如果你不需要真正的按地理位置个性化,就最简单:
1. 删除 `getClientIP()`
2. 删除调用 `api.ipify.org`
3. 删除 `X-Client-IP`
4. 后端删除 GeoIP 逻辑
5. `privacyMode` 可以保留,但默认应是更安全的行为
### 你应该删什么
- `src/utils/api.js` 中的 `getClientIP`
- `headers['X-Client-IP'] = clientIP`
- `backend/main.py``get_client_ip`
- `location = get_ip_location_text(client_ip)`
- `geoip.py` 如果以后不用可以移除
### 验收标准
- 前端网络面板中不再出现 `api.ipify.org`
- 后端不再接收 `X-Client-IP`
- prompt 不再包含用户位置
---
## 4. 后端把内部异常原样返回给前端
### 具体问题
- [backend/main.py:184](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L184)
- [backend/main.py:251](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L251)
- [backend/main.py:303](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L303)
现在写法是:
```python
return JSONResponse(content={"error": str(e)}, status_code=500)
```
### 错误原因
这是开发期为了调试方便常见的写法。
但线上不应该把真实异常直接发给浏览器。
### 会导致什么
- 暴露内部实现细节
- 暴露依赖报错、路径、上游信息
### 整改方式
统一改成:
1. 前端只收到固定错误码和通用提示
2. 后端日志里保留详细异常
3. 返回 request id 方便排查
### 建议响应格式
```json
{
"error": {
"code": "UPSTREAM_TIMEOUT",
"message": "Service temporarily unavailable",
"request_id": "xxxx"
}
}
```
### 验收标准
- 前端不再收到 Python 原始报错
- 日志可通过 request id 查到真实错误
---
## 5. `/v1/convert` 和 `/v1/ocr` 没有文件安全边界
### 具体问题
- [backend/main.py:239](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L239)
- [backend/main.py:268](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L268)
- [backend/main.py:275](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L275)
- [backend/main.py:281](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L281)
- [src/utils/convert.js:22](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js#L22)
现在的问题是:
- 直接 base64 解码
- 没有严格文件大小限制
- 没有严格文件类型白名单
- 没有魔数校验
- 没有超时和并发保护
### 错误原因
当前实现是功能优先,默认相信前端传来的内容。
但上传链路是最容易出问题的地方之一。
### 会导致什么
- 大文件压垮内存
- 恶意文件拖慢 CPU
- 第三方库处理异常文件时出故障
### 整改方式
#### 对 `ocr`
1. 图片大小上限先改为 5MB 或 10MB
2. 只允许 `jpg/png/webp`
3. 服务端校验 MIME 和魔数
4. 增加请求超时
5. 增加并发限制
#### 对 `convert`
1. 只允许明确白名单格式
2. 每种格式单独设大小上限
3. 服务端检查扩展名和文件头
4. `markitdown` 执行增加超时
5. 临时文件放到独立目录
6. 临时文件异常时也要清理
### 建议白名单
- `.pdf`
- `.docx`
- `.pptx`
- `.xlsx`
- `.md`
- `.txt`
### 建议直接拒绝
- 可执行文件
- 压缩包
- 未知二进制
- 超大图片
### 验收标准
- 超限文件返回 413
- 非法类型返回 415
- OCR/convert 高并发下不会拖垮服务
---
## 6. 没有限流,匿名站点很容易被刷
### 具体问题
- 当前代码里没有 rate limit
- 没有按 IP、UA、路径、时间窗做限制
- `ACTIVE_COMPLETIONS` 只处理取消,不是限流器。[backend/main.py:29](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L29)
### 错误原因
因为现在的代码默认是“正常用户正常使用”。
但匿名公网站点上线后,必须假设会被脚本反复调用。
### 会导致什么
- 模型成本失控
- CPU、内存、连接数被耗尽
- 服务变慢甚至不可用
### 整改方式
匿名站点不做登录,标准保护方式是限流:
1. 反向代理层限流
2. 应用层限流
3. 高成本接口单独限流
### 建议策略
#### `/v1/completions`
- 单 IP 每分钟 20 到 60 次
- 同时进行中的请求数限制 2 到 4 个
#### `/v1/ocr`
- 单 IP 每分钟 5 到 10 次
- 同时进行中的 OCR 限制更低
#### `/v1/convert`
- 单 IP 每分钟 3 到 5 次
- 强并发限制 1 到 2
### 还可以加什么
- Cloudflare Turnstile / hCaptcha 这类站点级防刷
- 对明显机器人流量加 challenge
这不需要登录,也不需要用户体系。
### 验收标准
- 连续脚本请求会命中 429
- 单个来源无法无限刷接口
---
## 7. 模型调用没有明确超时与失败策略
### 具体问题
- [backend/llm.py:15](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py#L15)
当前 `ollama.AsyncClient` 调用没有明显的统一超时和失败分类。
### 错误原因
开发阶段一般默认上游会正常返回。
但生产里,上游模型服务经常会出现:
- 变慢
- 卡住
- 连接失败
- 超时
### 会导致什么
- 请求挂很久
- 连接被占住
- 用户看起来像页面没响应
### 整改方式
1. completions 设置明确超时,例如 15 到 30 秒
2. OCR 设置更短或更明确的处理时限
3. convert 设置文件转换超时
4. 把错误分成:
- timeout
- unavailable
- bad response
5. 前端对这些错误做不同提示
### 验收标准
- 上游模型挂掉时,接口会快速失败而不是一直卡住
---
## 8. 缺少健康检查接口
### 具体问题
当前没有明确的:
- `/health/live`
- `/health/ready`
### 错误原因
项目还是开发态,没有进入正式部署思路。
### 会导致什么
- 你很难判断服务是否真的可用
- 容器/进程平台无法正确探活
### 整改方式
增加两个接口:
#### `/health/live`
只表示“应用进程活着”
#### `/health/ready`
表示“应用准备好服务请求”
这个接口至少检查:
- 模型上游是否可连接
- 关键配置是否存在
### 验收标准
- 反向代理或容器平台能用它判断是否接流量
---
## 9. 预览组件有 XSS 风险
### 具体问题
- [src/components/MarkdownPreview.vue:2](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue#L2)
- [src/components/MarkdownPreview.vue:19](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue#L19)
现在做法是:
- `v-html`
- `html: true`
### 错误原因
这意味着 markdown 里的原始 HTML 会被直接渲染。
如果内容来源不完全可信,这就是典型 XSS 入口。
### 会导致什么
- 恶意脚本执行
- 页面被注入恶意 DOM
### 整改方式
你有两个选择:
#### 方案 A:最简单,直接关掉 HTML
把:
```js
html: true
```
改成:
```js
html: false
```
#### 方案 B:保留 HTML,但做净化
1. 引入 DOMPurify
2. `md.render()` 后先 sanitize
3. 再给 `v-html`
### 推荐
如果你不是必须支持原始 HTML,直接用方案 A。
### 验收标准
- 恶意 markdown/HTML 不会执行脚本
---
## 10. 前端默认配置会误连固定服务地址
### 具体问题
- [src/utils/config.js:1](/C:/Users/ydy/Desktop/llm-in-text/src/utils/config.js#L1)
- [.env.example:1](/C:/Users/ydy/Desktop/llm-in-text/.env.example#L1)
- [backend/llm.py:12](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py#L12)
默认值里有固定公网域名和固定内网 IP。
### 错误原因
这是把“某次部署环境”写成了“代码默认值”。
### 会导致什么
- 本地开发可能误连生产或旧环境
- 新服务器部署时容易配错
### 整改方式
1. 默认值改成本地开发地址或空值
2. 关键配置不存在时直接报错
3. `.env.example` 只放模板,不放真实地址
### 建议
- `VITE_API_BASE_URL` 默认走同域,如 `''`
- 前端优先使用 `/v1/...` 反代
- 后端 `OLLAMA_HOST` 必须来自环境变量
### 验收标准
- 不配置环境变量时,不会误连旧服务
---
## 11. 包版本和界面版本不一致
### 具体问题
- [package.json:4](/C:/Users/ydy/Desktop/llm-in-text/package.json#L4) 是 `0.0.0`
- [src/components/SettingsPanel.vue:275](/C:/Users/ydy/Desktop/llm-in-text/src/components/SettingsPanel.vue#L275) 写的是 `v0.1.0-beta`
### 错误原因
一个是包元数据,一个是手写展示文案,没人保证同步。
### 会导致什么
- 发布后你都不确定线上到底是哪版
### 整改方式
1. 统一从 `package.json` 注入版本
2. 前端不要手写版本号
### 验收标准
- 页面显示版本和构建版本完全一致
---
## 12. `package.json` 缺少质量脚本
### 具体问题
- [package.json:6](/C:/Users/ydy/Desktop/llm-in-text/package.json#L6)
当前只有:
- `dev`
- `build`
- `preview`
没有:
- `test`
- `lint`
- `check`
### 错误原因
项目还停留在“能运行”的阶段,没有建立质量门禁。
### 会导致什么
- 任何改动都只能靠手工试
- 回归问题容易漏
### 整改方式
至少补这些脚本:
```json
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "pytest backend/tests -q",
"lint": "eslint src",
"check": "npm run lint && npm run build && pytest backend/tests -q"
}
```
如果前端暂时没配 ESLint,也至少先把 `test``check` 建起来。
### 验收标准
- 以后每次改代码前后都能统一执行 `check`
---
## 13. 测试覆盖不够,缺关键路径
### 具体问题
当前测试主要是:
- prompt 构造
- 取消逻辑
- LLM 消息结构
缺少:
- 匿名访问基本流程
- 错误响应格式
- OCR 文件限制
- convert 文件限制
- 限流
- XSS
### 错误原因
现有测试更偏功能开发时的局部验证,不是上线前测试矩阵。
### 整改方式
补这些测试:
1. completions 正常返回
2. completions 上游超时
3. completions 请求超长
4. OCR 非法类型
5. OCR 超大图片
6. convert 非法类型
7. convert 超大文件
8. 限流命中
9. 未授权方案移除后,匿名访问可正常工作
10. markdown 预览 XSS 样例
### 验收标准
- 关键错误分支都有自动化测试
---
## 14. Service Worker 缓存策略还不够稳
### 具体问题
- [src/main.js:13](/C:/Users/ydy/Desktop/llm-in-text/src/main.js#L13)
- [public/sw.js:1](/C:/Users/ydy/Desktop/llm-in-text/public/sw.js#L1)
当前是手写缓存逻辑,版本固定写死。
### 错误原因
这是一个能用的基础实现,但不适合长期生产维护。
### 会导致什么
- 更新后可能缓存混乱
- 老版本资源残留
### 整改方式
如果你不强依赖离线能力:
1. 先临时关闭 SW
2. 等核心功能稳定后再重做 PWA
如果要保留:
1. 用成熟方案接管,如 Vite PWA / Workbox
2. 资源按 hash 控制
3. 做更新提示
### 推荐
如果现在重点是先上线稳定版,先停掉 SW 更省事。
### 验收标准
- 用户刷新后不会出现随机旧资源
---
## 15. 构建体积偏大
### 具体问题
本次构建已经出现大 chunk 警告,尤其是 Mermaid 相关包比较重。
### 错误原因
图表、编辑器、语法高亮、数学渲染这类库本身就大。
现在又没有足够按功能懒加载。
### 会导致什么
- 首屏慢
- 弱网体验差
### 整改方式
1. Mermaid 按需加载
2. 预览按需加载
3. OCR / convert 相关 UI 按需加载
4. 收敛 `manualChunks`
### 验收标准
- 首页首次加载明显更轻
---
## 16. 匿名站点应该怎么做保护,而不是登录
这是你这个项目最关键的方向问题。
你不想做用户级网站,这完全可以。
那就按匿名站点的标准做:
### 必做
1. 去掉前端共享密钥
2. 收紧 CORS
3. 加 Nginx / Cloudflare / 网关限流
4. 应用层再做限流
5. 限制请求体大小
6. 限制 OCR/convert 并发
7. 错误信息脱敏
8. 加健康检查
9. 处理 XSS
### 可选
1. Cloudflare Turnstile
2. 简单的人机验证 challenge
3. 对高频匿名流量启用冷却时间
### 不必做
1. 登录
2. 注册
3. 用户系统
4. JWT
只要你的目标是匿名工具站,而不是多租户平台,上面这套就够了。
---
## 最简修复顺序
如果你要最低成本把项目拉到“能较安全公开上线”的程度,建议顺序是:
1. 删除前端 API key 和后端固定 key 校验
2. 删除 IP 获取和地理位置推断
3. 收紧 CORS
4. 统一错误响应
5. 给 OCR/convert 加大小、类型、超时限制
6. 加限流
7. 修掉 `MarkdownPreview` 的 XSS 风险
8. 增加 `/health/live``/health/ready`
9.`test` / `check` 脚本
10. 视情况先关闭 service worker
---
## 这份清单对应的文件
- [backend/main.py](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py)
- [backend/llm.py](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py)
- [src/utils/api.js](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js)
- [src/utils/convert.js](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js)
- [src/components/MarkdownPreview.vue](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue)
- [src/stores/settings.js](/C:/Users/ydy/Desktop/llm-in-text/src/stores/settings.js)
- [src/components/SettingsPanel.vue](/C:/Users/ydy/Desktop/llm-in-text/src/components/SettingsPanel.vue)
- [public/sw.js](/C:/Users/ydy/Desktop/llm-in-text/public/sw.js)
- [package.json](/C:/Users/ydy/Desktop/llm-in-text/package.json)
+579
View File
@@ -0,0 +1,579 @@
# llm-in-text 生产环境修复清单
## 文档目的
本文档是针对当前仓库在 2026-04-01 状态下基于代码的生产就绪性审查。
它回答三个问题:
1. 当前项目是否已准备好投入生产?
2. 自上次审查以来已修复了哪些问题?
3. 还有什么因素阻碍安全上线?
本次审查仅限于仓库中已有的内容和本地直接验证的内容,不包括外部基础设施、反向代理配置、云资源、CI 平台密钥或运行时运维的完整审计。
## 审查范围
- 前端构建和运行时入口
- 后端 FastAPI 端点和模型集成
- 补全、OCR 和文件转换请求路径
- 隐私相关行为和本地存储
- 基础测试覆盖率和构建验证
- PWA/Service Worker 状态
- 仓库中存在的部署和运维工件
## 已验证的事实
以下项目已针对当前仓库直接验证:
- 前端生产构建通过 `npm.cmd run build` 成功
- 后端测试通过 `pytest backend/tests -q`
- 当前后端测试结果:`8 passed, 1 skipped`
- 存在健康检查端点:`/health/live``/health/ready`
- 前端不再硬编码 API 密钥
- 后端不再需要旧的 `X-API-Key`
- 前端隐私模式现在默认为启用
- 后端错误响应现在已规范化,不再返回原始异常字符串
- OCR 和转换端点现在强制执行基本的文件大小和扩展名检查
## 当前评估
项目尚未准备好投入生产。
当前状态更接近于:
- 一个可用的原型
- 内部演示
- 具有部分加固的预发布候选版本
由于缺少几个核心生产基线,它尚未准备好面向互联网的生产使用:
- 真正的限流和并发保护
- 正式的部署工件和运行时拓扑
- CI/CD 和自动化质量门禁
- 结构化的可观测性和告警
- 更强的请求验证和更安全的文件处理隔离
- 前端自动化测试和端到端验证
## 已修复的问题
与之前的清单相比,以下项目不再作为阻碍因素:
### 已修复:硬编码的前端/后端共享 API 密钥
- `src/utils/api.js` 不再发送 `X-API-Key`
- `src/utils/convert.js` 不再发送 `X-API-Key`
- `backend/main.py` 不再强制执行旧的静态密钥
这消除了上次审查中最严重的问题之一。
遗留问题:
- `backend/tests/test_main_cancel.py` 仍然包含过时的 `X-API-Key` 头,但它们目前是无效的,表明测试假设已过时而非活动中的认证逻辑
### 已修复:危险的通配符 CORS 配置
当前后端 CORS 限制为:
- `http://localhost:5173`
- `http://localhost:3000`
并使用:
- `allow_credentials=False`
- `allow_methods=["POST", "OPTIONS"]`
- `allow_headers=["Content-Type", "X-Request-Id"]`
这比之前的通配符配置安全得多。
遗留问题:
- CORS 仍然针对本地开发硬编码,对于 staging/生产环境不是环境驱动的
### 已修复:默认收集前端公网 IP
- `src/stores/settings.js` 现在将 `privacyMode` 默认为 `true`
- `src/utils/api.js` 不再调用 `ipify`
- `src/utils/api.js` 不再发送 `X-Client-IP`
- `backend/main.py` 不再将 IP 派生的位置注入提示词
遗留问题:
- `backend/geoip.py` 和 GeoLite 数据库仍然存在于仓库中,这可能会造成对当前隐私模型的混淆
### 已修复:向客户端暴露原始异常字符串
当前后端响应使用 `_error_response(...)` 和结构化载荷,例如:
- `INTERNAL_ERROR`
- `OCR_FAILED`
- `CONVERT_FAILED`
- `FILE_TOO_LARGE`
- `INVALID_FILE_TYPE`
这比直接返回 `str(exception)` 更好。
遗留问题:
- 日志仍然记录用户派生内容的预览,这是另一个隐私/可观测性问题
### 部分修复:上传和转换输入边界
`backend/main.py` 中的当前后端保护包括:
- OCR 大小上限:10 MB
- 转换大小上限:50 MB
- OCR 和转换的扩展名白名单
-`finally` 中清理临时转换文件
这是有意义的进展,但不足以投入生产。
## 生产阻碍因素
优先级含义:
- `P0`:必须在生产上线前完成
- `P1`:应在公开发布或广泛推广前完成
- `P2`:重要的后续加固和维护工作
---
## P0 阻碍因素
### P0-01 声明了限流和并发控制但未强制执行
当前状态:
- `backend/main.py` 定义了 `MAX_CONCURRENT_COMPLETIONS = 4`
- `backend/main.py` 定义了 `COMPLETION_RATE_LIMIT = 60`
- 没有任何实际的限流器使用这两个值
- OCR 和转换端点也没有真正的每客户端节流
风险:
- 容易滥用昂贵的补全/OCR/转换端点
- 可避免地对模型主机、CPU、内存和临时存储造成过载
- 突发流量下无法控制降级
必需的修复:
1. 在应用或网关中强制执行真正的每路由限流
2. 为补全作业添加真正的并发保护
3.`/v1/completions``/v1/ocr``/v1/convert` 添加单独预算
4. 达到限制时返回明确的 `429`
5. 为节流的请求和队列深度发出指标
验收标准:
- 重复的突发流量触发确定性的 `429`
- 并发补全不能超过配置的预算
- 负载测试下服务保持稳定
---
### P0-02 仓库中不存在生产部署基线
当前状态:
- 后端仅通过 `uvicorn.run(...)` 暴露开发式启动
- 没有 `Dockerfile`
- 没有 compose 文件
- 没有 Kubernetes 清单或 Helm chart
- 没有 systemd 单元
- 没有反向代理参考配置
- 没有记录的生产环境契约
风险:
- 没有可复现的部署路径
- 没有明确的过程监督、重启或优雅的发布模型
- 没有记录的 ingress/请求体大小/超时/TLS 姿态
必需的修复:
1. 定义一个官方部署目标
2. 为该目标添加部署工件
3. 记录所需的环境变量、端口、探针和存储
4. 定义优雅关闭和发布行为
5. 记录反向代理限制和信任边界
验收标准:
- 新环境可以从仓库文档和工件部署
- 健康探针已接入所选运行时
- 回滚路径已记录
---
### P0-03 缺少 CI/CD 和仓库质量门禁
当前状态:
- 仓库级别没有找到 `.github/workflows`
- `package.json` 没有真正的前端测试脚本
- 没有 lint 脚本
- 没有类型检查脚本
- 没有依赖扫描或密钥扫描工作流
风险:
- 回归只能手动捕获
- 安全和打包漂移很可能发生
- 生产就绪性取决于本地开发者的规范
必需的修复:
1. 为构建和测试添加 CI 工作流
2. 添加前端自动化测试
3. 在适用的情况下添加 lint 和类型检查门禁
4. 添加依赖漏洞扫描
5. 添加密钥扫描和基本 SAST
验收标准:
- 每个 PR 都运行构建、后端测试、前端测试和 lint
- 失败的检查阻止合并
---
### P0-04 文件处理路径仍然缺乏生产级隔离
当前状态:
- 后端将完整 base64 载荷解码到内存中
- 转换写入临时文件并将其传递给 `markitdown`
- OCR 和转换主要依赖扩展名检查,而不是内容嗅探
- 没有工作进程隔离或用于转换的单独沙箱
- 转换任务没有队列或资源预算
风险:
- 大型或并发上传导致内存峰值
- 畸形或对抗性文档会给解析器带来压力
- 转换工作负载可能干扰核心补全可用性
必需的修复:
1. 明确验证 base64 解码失败
2. 添加 MIME/内容嗅探,而不仅仅是扩展名检查
3. 在入口和应用层添加更低的、路由特定的请求体限制
4. 将转换隔离到单独的工作进程/进程边界
5. 添加超时、并发上限和队列深度控制
验收标准:
- 畸形载荷失败并返回明确的 4xx 响应
- 转换不能饿死补全服务
- 压力下临时文件和内存增长保持有界
---
### P0-05 环境配置不一致且不安全
当前状态:
- 前端 `.env.example` 相当安全
- 后端 `.env.example` 过时且与代码不一致
- 代码读取 `OLLAMA_HOST`
- 后端示例仍然使用 `OLLAMA_BASE_URL`
- 后端示例仍然定义 `OPENAI_API_KEY=ollama`,这是误导性的
风险:
- 新环境配置不正确
- 运营商可能假设不支持的认证/配置行为
- staging/生产漂移很可能发生
必需的修复:
1. 用代码实际使用的变量替换后端环境示例
2. 在启动时验证所需的环境变量
3. 分离开发、staging 和生产环境契约
4. 缺失关键配置时快速失败
验收标准:
- 示例环境文件匹配真实运行时行为
- 无效或缺失关键配置导致启动失败
---
## P1 高优先级差距
### P1-01 日志仍然捕获用户派生内容预览
当前状态:
- `backend/main.py` 记录提示词派生的前缀和后缀预览
- 补全结果记录包含内容预览
- OCR 和转换记录文本预览长度和片段
风险:
- 日志可能包含敏感文档内容
- 隐私姿态与应用可见的隐私设置不一致
- 难以证明保留/合规姿态
必需的修复:
1. 默认情况下停止记录用户内容正文和预览
2. 仅保留请求元数据:路由、请求 ID、状态、延迟、大小
3. 引入结构化 JSON 日志
4. 脱敏或哈希任何敏感标识符
验收标准:
- 默认日志不包含用户文档文本
- 请求关联仍可通过请求 ID 和元数据工作
### P1-02 请求验证仍然过于宽松
当前状态:
- Pydantic 模型定义了字段但没有长度或枚举约束
- `prefix``suffix``filename``reason` 受到极小约束
- base64 字段在路由特定检查之前仍然可能非常大
- 前端转换路径在将完整文件读入 base64 之前不执行预验证
风险:
- 过大或畸形的请求太容易到达昂贵的逻辑
- 端点之间的 4xx 行为不一致
必需的修复:
1. 为 Pydantic 字段添加长度和枚举约束
2. 明确验证 base64 格式
3. 更防御性地规范化文件名处理
4. 添加前端预检查大小/类型作为 UX
验收标准:
- 畸形请求尽早失败并返回确定性的 4xx 响应
### P1-03 前端自动化覆盖率基本缺失
当前状态:
- 后端有针对性的单元/集成风格测试
- 前端没有配置测试运行器
- 核心用户路径没有 E2E 覆盖率
重要说明:
- `backend/tests/test_main_cancel.py` 仍然发送过时的 `X-API-Key` 头;测试通过仅仅是因为后端忽略它们
风险:
- 编辑器、上传、OCR、转换和设置的回归将会遗漏
必需的修复:
1. 添加前端单元/组件测试
2. 为补全、取消、上传、OCR 和转换添加 E2E 覆盖率
3. 从后端测试中移除过时的认证假设
验收标准:
- 核心用户旅程在 CI 中自动覆盖
### P1-04 健康检查端点存在,但就绪性浅且缺少可观测性
当前状态:
- `/health/live` 存在
- `/health/ready` 存在
- 就绪性实际上不检查上游模型可用性
- 没有指标端点
- 没有追踪
- 没有告警定义
风险:
- 运行时故障检测太晚
- 平台探针可能报告健康而上游依赖不可用
必需的修复:
1. 使就绪性反映关键依赖状态
2. 添加请求/延迟/错误指标
3. 为上游故障和饱和添加告警阈值
4. 为关键路由定义仪表板
验收标准:
- 运营商可以快速检测模型依赖故障
- 请求成功率和延迟可观测
### P1-05 Service Worker 实现存在但被禁用
当前状态:
- `public/sw.js` 存在
- `src/main.js``&& false` 硬禁用注册
- Service Worker 策略是手写的并通过静态缓存名称版本化
风险:
- 当前仓库包含未被实际使用的休眠 PWA 逻辑
- 如果随意重新启用,更新和缓存行为可能很脆弱
必需的修复:
1. 确定 PWA 是否在生产范围内
2. 如果是,采用维护的策略如 Vite PWA/Workbox
3. 如果不是,移除无用的 Service Worker 代码以减少混淆
验收标准:
- PWA 行为要么被有意支持和测试,要么被完全移除
### P1-06 背景图像持久化可能导致本地存储和内存膨胀
当前状态:
- 设置面板将上传的背景图像读取为 data URL
- 背景图像数据存储在 localStorage 中
- 没有对背景资产强制执行明确的大小上限
风险:
- 存储配额耗尽
- 大图像导致 UI 缓慢
- 跨浏览器的持久化行为脆弱
必需的修复:
1. 读取前在客户端添加大小上限
2. 持久化前调整大小/压缩
3. 对于较大的资产,优先使用 blob/object URL 或 IndexedDB
4. 为存储溢出添加迁移/错误处理
验收标准:
- 大图像不能降低启动或破坏设置持久化
---
## P2 重要后续工作
### P2-01 构建成功,但 bundle/chunk 策略仍然粗糙
验证的构建输出显示:
- `manualChunks` 生成许多空 chunk
- 一个与 Mermaid 相关的大型 chunk 超过 1 MB 压缩后
风险:
- 不必要的 chunk 开销
- 较弱的设备上冷启动较慢
必需的修复:
1. 简化 `manualChunks`
2. 延迟加载重型可选功能
3. 清理 chunk 后重新测量首次加载成本
### P2-02 OCR 缓存和图像哈希缓存没有明确的驱逐策略
当前状态:
- OCR 数据存储在内存中的 `Map`
- 哈希缓存也在内存中
- 没有 TTL
- 没有最大条目数
风险:
- 长时间会话会累积内存
必需的修复:
1. 添加 TTL 和条目边界
2. 如需要,暴露缓存指标用于调试
### P2-03 仓库仍然包含过时和混淆的工件
示例:
- `backend/geoip.py` 和 GeoLite DB 仍然存在,尽管 IP 地理定位在请求流程中不再活跃
- `backend/.env.example` 记录的变量不是代码使用的
- 后端测试仍然包含过时的 `X-API-Key`
风险:
- 未来维护者可能无意中重新引入已移除的行为
必需的修复:
1. 移除死代码和过时配置
2. 使测试和文档与当前实现保持一致
---
## 上线前必需的缺失证据
仓库目前不提供以下生产能力的证据:
- staging 部署管道
- 回滚程序
- 流量/负载测试结果
- 故障注入或混沌测试
- 备份/恢复程序
- 事件响应运行手册
- SLO/SLA 定义
- 安全扫描基线
- 依赖更新策略
- 隐私/数据保留文档
目前应将证据缺失视为未就绪,而不是隐式完成。
## 推荐的修复顺序
### 第一阶段:解除生产上线阻碍
1. 实现真正的限流和并发强制执行
2. 定义官方部署拓扑和工件
3. 添加 CI/CD 质量门禁
4. 加固和隔离文件处理工作负载
5. 修复后端环境配置契约
### 第二阶段:稳定运维和隐私姿态
1. 移除承载内容的日志
2. 加强请求验证
3. 深化就绪检查和指标
4. 添加前端和 E2E 自动化测试
### 第三阶段:性能和可维护性清理
1. 清理 chunk 策略
2. 限制 OCR/图像缓存
3. 移除过时代码和配置
4. 决定 PWA 支持是保留还是移除
## 最低上线门槛
至少在以下所有条件都满足之前,不应称该项目为生产就绪:
- 所有 `P0` 项目都已完成
- 日志不再捕获用户内容
- 前端和端到端自动化测试存在并在 CI 中运行
- 就绪性反映真实的上游依赖状态
- 部署和回滚已记录且可重现
- staging 环境已通过集成验证
- 至少执行了一次受控负载测试并经过审查
## 最终评估
与之前的清单相比,该项目已有实质性改进。几个严重的早期发现不再成立,特别是:
- 硬编码的认证密钥暴露
- 通配符式 CORS 姿态
- 默认公网 IP 收集
- 原始异常泄漏
然而,这一进展并不意味着已准备好投入生产。
当前仓库展示了有用的加固工作,但仍然缺乏生产服务预期的运维、测试、节流、部署和可观测性基线。
+19 -11
View File
@@ -9,9 +9,14 @@ from dotenv import load_dotenv
load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
# Timeouts in seconds
COMPLETION_TIMEOUT = 30
OCR_TIMEOUT = 60
CONVERT_TIMEOUT = 30
client = ollama.AsyncClient(host=OLLAMA_HOST)
logger = logging.getLogger("llm")
@@ -97,7 +102,7 @@ async def call_ollama(
if thinking:
kwargs["think"] = thinking
response = await client.chat(**kwargs)
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
@@ -156,15 +161,18 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
)
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}
response = await asyncio.wait_for(
client.chat(
model=VLM_MODEL,
messages=[{
'role': 'user',
'content': VLM_OCR_CONTEXT_PROMPT,
'images': [image_bytes]
}],
stream=False,
options={'temperature': 0.3}
),
timeout=OCR_TIMEOUT
)
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
+144 -43
View File
@@ -2,18 +2,19 @@ import asyncio
import base64
import json
import logging
import os
import tempfile
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import call_ollama, call_vlm_ocr
from prompt import build_completion_prompts, prepare_prompt_context
import markitdown
logging.basicConfig(
level=logging.INFO,
@@ -23,28 +24,33 @@ logger = logging.getLogger("api")
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:3000",
"https://www.imageteach.tech",
"https://chat.imageteach.tech",
],
allow_credentials=False,
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type", "X-Request-Id"],
)
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
)
# Rate limiting
MAX_CONCURRENT_COMPLETIONS = 4
COMPLETION_RATE_LIMIT = 60 # per minute
API_KEY = "your-secret-key-here"
api_key_header = APIKeyHeader(name="X-API-Key")
# File size limits (bytes)
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_CONVERT_SIZE = 50 * 1024 * 1024 # 50MB
async def get_api_key(api_key: str = Security(api_key_header)):
if api_key != API_KEY:
raise HTTPException(
status_code=403,
detail="Could not validate credentials",
)
return api_key
# Allowed file extensions
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_CONVERT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"}
class UserPreferences(BaseModel):
@@ -73,6 +79,11 @@ class OCRRequest(BaseModel):
language: str = "auto"
class ConvertRequest(BaseModel):
file: str
filename: str = "document.pdf"
def _preview(text: str, limit: int = 80) -> str:
value = (text or "").replace("\n", "\\n")
if len(value) <= limit:
@@ -80,37 +91,34 @@ def _preview(text: str, limit: int = 80) -> str:
return value[:limit] + "..."
def _error_response(request_id: str, code: str, message: str, status_code: int = 500) -> JSONResponse:
return JSONResponse(
content={
"error": {
"code": code,
"message": message,
"request_id": request_id,
}
},
status_code=status_code,
)
def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
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"
@app.post("/v1/completions")
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
async def create_completion(request: Request, req: CompletionRequest):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
inference_task: Optional[asyncio.Task] = None
client_ip = "hidden"
location = ""
if not req.privacy_mode:
client_ip = get_client_ip(request)
location = get_ip_location_text(client_ip)
if location:
logger.info("[%s] client_location=%s", request_tag, location)
try:
logger.info(
"[%s] /v1/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
"[%s] /v1/completions request_id=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
request_tag,
request_id,
client_ip,
len(req.prefix or ""),
len(req.suffix or ""),
req.languageId,
@@ -126,7 +134,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences,
)
@@ -173,7 +180,7 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
return StreamingResponse(cancelled(), media_type="text/event-stream")
except Exception as e:
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
return _error_response(request_id, "INTERNAL_ERROR", "Service temporarily unavailable", 500)
finally:
async with ACTIVE_COMPLETIONS_LOCK:
active = ACTIVE_COMPLETIONS.get(request_id)
@@ -182,7 +189,7 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
async def cancel_completion(req: CancelCompletionRequest):
request_tag = str(uuid.uuid4())[:8]
request_id = req.request_id or ""
@@ -218,7 +225,7 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
@app.post("/v1/ocr")
async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
async def ocr_image(request: OCRRequest):
request_id = str(uuid.uuid4())[:8]
try:
logger.info(
@@ -228,7 +235,22 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
request.language,
len(request.image or ""),
)
# Check file size before decoding
if len(request.image or "") > MAX_IMAGE_SIZE * 4 // 3: # base64 overhead
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
# Check extension
ext = os.path.splitext(request.filename)[1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
return _error_response(request_id, "INVALID_FILE_TYPE", "Only jpg/png/webp allowed", 415)
image_bytes = base64.b64decode(request.image)
# Check actual decoded size
if len(image_bytes) > MAX_IMAGE_SIZE:
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
result = await call_vlm_ocr(image_bytes, request.language)
logger.info(
@@ -240,10 +262,89 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
return {"text": result, "filename": request.filename}
except Exception as e:
logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
return _error_response(request_id, "OCR_FAILED", "Failed to process image", 500)
@app.post("/v1/convert")
async def convert_to_markdown(request: ConvertRequest):
"""将文件转换为Markdown格式"""
request_id = str(uuid.uuid4())[:8]
try:
logger.info(
"[%s] /v1/convert filename=%s file_base64_chars=%d",
request_id,
request.filename,
len(request.file or ""),
)
# Check file size before decoding
if len(request.file or "") > MAX_CONVERT_SIZE * 4 // 3:
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
# Get file extension and validate
ext = os.path.splitext(request.filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
return _error_response(request_id, "INVALID_FILE_TYPE", "Only pdf/docx/pptx/xlsx/md/txt allowed", 415)
# 解码Base64文件内容
file_bytes = base64.b64decode(request.file)
# Check actual decoded size
if len(file_bytes) > MAX_CONVERT_SIZE:
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# 创建临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
# 使用MarkItDown转换为Markdown
md = markitdown.MarkItDown()
result = md.convert(tmp_path)
markdown_text = result.text_content
logger.info(
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
request_id,
len(markdown_text or ""),
_preview(markdown_text, 120),
)
return {
"markdown": markdown_text,
"filename": request.filename
}
finally:
# 清理临时文件
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.exception("[%s] /v1/convert failed: %s", request_id, e)
return _error_response(request_id, "CONVERT_FAILED", "Failed to convert file", 500)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
@app.get("/health/live")
async def health_live():
return {"status": "ok"}
@app.get("/health/ready")
async def health_ready():
# Check if critical components are available
try:
# Could add more checks here (e.g., Ollama connectivity)
return {"status": "ready"}
except Exception as e:
logger.warning("[health/ready] not ready: %s", e)
return _error_response("health-check", "NOT_READY", "Service not ready", 503)
+205 -2
View File
@@ -127,8 +127,205 @@ def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]:
return _prepare_context(prefix, suffix)
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)
def _language_guidance(language_id: str) -> str:
canonical = _canonical_language_id(language_id)
if canonical == "markdown":
return ""
if canonical == "mermaid":
return """
Language-specific guidance (mermaid):
- Output valid Mermaid syntax only.
- Prefer concise, syntactically correct diagram statements.
- Avoid prose unless the user prompt explicitly requires it."""
if canonical == "latex":
return """
Language-specific guidance (latex):
- Output LaTeX math content only when completing LaTeX.
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
- Output raw LaTeX lines only.
- Do not wrap with $ or $$."""
if canonical == "json":
return """
Language-specific guidance (json):
- Output strict JSON only (no comments, no trailing commas).
- Ensure valid quotes and braces."""
if canonical == "yaml":
return """
Language-specific guidance (yaml):
- Output valid YAML only.
- Use consistent indentation and avoid tabs."""
if canonical == "toml":
return """
Language-specific guidance (toml):
- Output valid TOML only.
- Keep key types consistent."""
if canonical == "ini":
return """
Language-specific guidance (ini):
- Output valid INI only.
- Keep section headers and key=value pairs consistent."""
if canonical == "sql":
return """
Language-specific guidance (sql):
- Output a single, valid SQL statement unless context requires multiple.
- Prefer ANSI SQL when dialect is unclear."""
if canonical == "bash":
return """
Language-specific guidance (bash):
- Output POSIX-compatible shell when possible.
- Avoid interactive prompts or destructive commands unless requested."""
if canonical == "powershell":
return """
Language-specific guidance (powershell):
- Output valid PowerShell commands.
- Avoid destructive commands unless explicitly requested."""
if canonical == "html":
return """
Language-specific guidance (html):
- Output valid HTML only.
- Keep markup minimal and well-formed."""
if canonical == "css":
return """
Language-specific guidance (css):
- Output valid CSS only.
- Use concise, readable selectors."""
if canonical == "diff":
return """
Language-specific guidance (diff):
- Output a unified diff only.
- Ensure @@ hunk headers and +/- lines are consistent."""
if canonical == "regex":
return """
Language-specific guidance (regex):
- Output the regex pattern only.
- Avoid delimiters unless explicitly requested."""
if canonical in {"javascript", "typescript"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Prefer modern syntax and avoid prose unless comments are needed."""
if canonical in {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Avoid prose unless context clearly expects comments or docstrings."""
if canonical == "text":
return """
Language-specific guidance (text):
- Output plain text only.
- Avoid markdown formatting unless explicitly asked."""
if canonical == "xml":
return """
Language-specific guidance (xml):
- Output well-formed XML only.
- Ensure matching tags and proper escaping."""
if canonical == "dockerfile":
return """
Language-specific guidance (dockerfile):
- Output valid Dockerfile instructions only.
- Keep layers minimal and ordered logically."""
if canonical == "makefile":
return """
Language-specific guidance (makefile):
- Output valid Makefile syntax only.
- Use tabs for recipe lines."""
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Avoid prose unless context clearly expects comments or docstrings."""
def build_inline_system_prompt(language_id: str = "markdown") -> str:
safe_language_id = _sanitize_language_id(language_id)
safe_language_id = _canonical_language_id(language_id)
language_guidance = _language_guidance(safe_language_id)
system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions.
Return only the insertion text that should be placed between PREFIX and SUFFIX.
@@ -142,6 +339,8 @@ Hard constraints you must follow:
- If you output any math expression, it must be strict KaTeX-compatible math.
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
- Never output bare formulas without $ or $$ wrappers.
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex,
output raw LaTeX without $ or $$ wrappers.
3) Strict code formatting:
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
@@ -187,6 +386,10 @@ Hard constraints you must follow:
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
- Never output any OCR tag.
- Never output OCR tag fragments such as <OCR:...>."""
if language_guidance:
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
return system_prompt.strip()
@@ -319,7 +522,7 @@ def build_completion_prompts(
thinking_level: str = "low",
preferences: object = None,
) -> Tuple[str, str]:
safe_language_id = _sanitize_language_id(language_id)
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)
+5
View File
@@ -5,3 +5,8 @@ pydantic
python-dotenv
httpx
geoip2
markitdown[all]
python-docx
python-pptx
openpyxl
pypdf
+1020 -877
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "echo 'No tests configured yet'",
"check": "npm run build"
},
"dependencies": {
"@milkdown/core": "^7.18.0",
+3 -4
View File
@@ -17,16 +17,15 @@ function onChange(markdownValue) {
const appStyle = computed(() => {
const style = {}
if (settings.backgroundType === 'warm') {
style.background = '#fdfaf3ff' // Very light warm yellow
style.background = '#fdfaf3ff'
style.color = '#5d4037'
} else if (settings.backgroundType === 'reading') {
style.background = '#f5f0e1' // Slightly deeper warm paper color
style.background = '#f5f0e1'
style.color = '#333333'
} else if (settings.backgroundType === 'default') {
style.background = 'var(--app-bg)'
style.color = 'var(--app-text)'
} else if (settings.backgroundType === 'image') {
// For image background, we might want a solid base color underlying it
style.background = settings.theme === 'dark' ? '#000000' : '#ffffff'
}
return style
@@ -69,7 +68,7 @@ const backgroundStyle = computed(() => {
background: var(--app-bg);
color: var(--app-text);
transition: background 0.3s, color 0.3s;
isolation: isolate; /* Create new stacking context */
isolation: isolate;
}
.editor-container {
+250
View File
@@ -0,0 +1,250 @@
<template>
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
<div class="doc-header">
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<div class="doc-name">{{ docName }}</div>
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<div class="doc-editor" v-show="!isCollapsed">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
const props = defineProps({
docType: { type: String, default: 'text' },
docName: { type: String, default: 'document.txt' },
uploadTime: { type: String, default: '' },
initialContent: { type: String, default: '' }
})
const emit = defineEmits(['update:content', 'delete'])
const editorRoot = ref(null)
const isCollapsed = ref(false)
let crepe = null
let internalChangeTimer = null
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
if (!crepe) return
crepe.getMarkdown().then(markdown => {
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
})
}
const syncContent = () => {
if (!crepe) return
if (internalChangeTimer) clearTimeout(internalChangeTimer)
internalChangeTimer = setTimeout(async () => {
const markdown = await crepe.getMarkdown()
emit('update:content', markdown)
}, 120)
}
onMounted(async () => {
if (!editorRoot.value) return
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent || '',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.ListCheck]: true,
},
config: { showLineNumber: false }
})
crepe.editor.config(ctx => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 1000
})
})
crepe.editor.use(copilotPlugin)
await crepe.create()
crepe.on(listener => {
listener.updated(() => {
syncContent()
})
})
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, true)
})
})
watch(() => props.initialContent, (newVal) => {
if (crepe && newVal !== undefined) {
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const currentPos = view.state.selection.from
view.dispatch(view.state.tr.insertText(newVal))
})
}
})
onUnmounted(() => {
if (internalChangeTimer) clearTimeout(internalChangeTimer)
if (crepe) {
crepe.destroy()
crepe = null
}
})
defineExpose({
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
getEditor: () => crepe
})
</script>
<style scoped>
.doc-block-crepe {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block-crepe.collapsed .doc-editor {
display: none;
}
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
}
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
.doc-editor {
padding: 8px;
background: var(--crepe-color-surface-low);
min-height: 120px;
max-height: 400px;
overflow-y: auto;
}
.inner-crepe {
width: 100%;
height: 100%;
}
.inner-crepe :deep(.milkdown) {
background: transparent !important;
}
.inner-crepe :deep(.ProseMirror) {
min-height: 80px;
padding: 8px !important;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="doc-block" :class="{ collapsed: isCollapsed }">
<!-- 深色条文件头 -->
<div class="doc-header">
<!-- 最左边文件类型icon -->
<div class="doc-icon">
<!-- PDF icon -->
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<!-- Word icon -->
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<!-- PPT icon -->
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<!-- TXT icon -->
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<!-- 中间文件名 -->
<div class="doc-name">{{ docName }}</div>
<!-- 最右边下载按钮 + 折叠按钮 -->
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- 浅色块文档内容非折叠状态显示 -->
<div class="doc-content" v-show="!isCollapsed">
<pre>{{ content }}</pre>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
docType: {
type: String,
default: 'text'
},
docName: {
type: String,
default: 'document.txt'
},
uploadTime: {
type: String,
default: ''
},
content: {
type: String,
default: ''
}
})
const isCollapsed = ref(false)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
const blob = new Blob([props.content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.doc-block {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block.collapsed .doc-content {
display: none;
}
/* 深色条 */
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
/* 文件类型icon */
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
}
/* 文件名 */
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
/* 浅色块:文档内容 */
.doc-content {
padding: 12px;
background: var(--crepe-color-surface-low);
max-height: 400px;
overflow-y: auto;
}
.doc-content pre {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--crepe-color-on-surface);
white-space: pre-wrap;
word-break: break-word;
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@ const props = defineProps({
})
const md = new MarkdownIt({
html: true,
html: false,
linkify: true,
typographer: true
})
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -2,10 +2,13 @@
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import packageJson from '../../package.json'
const store = useSettingsStore()
const { setTheme } = useTheme()
const VERSION = packageJson.version || '0.0.0'
const isOpen = ref(false)
let systemThemeMediaQuery = null
@@ -113,7 +116,6 @@ const appearanceMode = computed({
}
})
// Background Image Handling
const handleImageUpload = (event) => {
const file = event.target.files[0]
if (file) {
@@ -126,7 +128,6 @@ const handleImageUpload = (event) => {
}
}
// Helper to translate
const t = (key) => store.t[key]
</script>
@@ -274,7 +275,7 @@ const t = (key) => store.t[key]
<div class="about-card">
<h4>llm-in-text</h4>
<p>A smart Markdown editor with local LLM intelligence.</p>
<p class="version">v0.1.0-beta</p>
<p class="version">v{{ VERSION }}</p>
</div>
</section>
</div>
+3 -3
View File
@@ -10,10 +10,10 @@ const app = createApp(App)
app.use(createPinia())
app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed:', error)
navigator.serviceWorker.register('/sw.js').catch(() => {
// Service worker registration failed, silently ignore
})
})
}
+95 -17
View File
@@ -9,6 +9,7 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface CopilotState {
@@ -18,7 +19,7 @@ interface CopilotState {
}
interface CopilotConfig {
fetchSuggestion: (prefix: string, suffix: string, signal?: AbortSignal) => Promise<string>
fetchSuggestion: (prefix: string, suffix: string, languageId: string, signal?: AbortSignal) => Promise<string>
debounceMs?: number
}
@@ -176,7 +177,7 @@ function normalizeSuggestionText(raw: string): string {
text = parsed.replace(/\r\n?/g, '\n')
}
} catch {
// Keep original text when not valid JSON.
// Not valid JSON, keep original
}
}
@@ -187,13 +188,58 @@ function normalizeSuggestionText(raw: string): string {
return text
}
function sanitizeLanguageId(value: string): string {
if (!value) return ''
const trimmed = value.trim()
if (!trimmed) return ''
let safe = ''
for (const ch of trimmed) {
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '-' || ch === '_' || ch === '+' || ch === '.') {
safe += ch
}
}
return safe.slice(0, 32).toLowerCase()
}
function getCodeBlockLanguage(node: ProseNode): string {
const candidates = [node.attrs?.language, node.attrs?.lang, node.attrs?.info]
for (const value of candidates) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
}
function getCursorLanguageId(view: EditorView): string {
const fallback = 'markdown'
const { $from } = view.state.selection
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth)
const typeName = node.type?.name || ''
if (typeName === 'math_inline' || typeName === 'math_block' || typeName === 'latex' || typeName === 'math') {
return 'latex'
}
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
const lang = sanitizeLanguageId(getCodeBlockLanguage(node))
if (!lang) return fallback
if (lang === 'tex' || lang === 'latex' || lang === 'katex') return 'latex'
return lang
}
}
return fallback
}
async function insertGhostText(view: EditorView, suggestion: string, from: number, ctx: Ctx) {
if (!suggestion) return
const markType = view.state.schema.marks.copilot_ghost
if (!markType) {
console.error('[Copilot] copilot_ghost mark not found in schema')
return
}
@@ -210,7 +256,6 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe
const insertedRange = insertParsedMarkdownSlice(tr, from, parsedDoc)
if (!insertedRange) {
console.warn('[Copilot] parsed markdown insertion failed, falling back to plain text')
insertPlainText(view, suggestion, from, markType)
return
}
@@ -219,8 +264,7 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe
tr.setSelection(Selection.near(tr.doc.resolve(getCursorBeforeGhostInsert(tr, from)), -1))
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertedRange.from, to: insertedRange.to, suggestion })
view.dispatch(tr)
} catch (e) {
console.error('[Copilot] Parser error:', e)
} catch {
insertPlainText(view, suggestion, from, markType)
}
}
@@ -287,6 +331,31 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
return `\n\n${lines.join('\n')}`
}
// 从markdown中提取文档块内容用于AI补全上下文
function extractDocBlocksFromMarkdown(markdown: string): string {
const lines: string[] = []
// 使用正则表达式匹配文档块
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
let match
while ((match = docBlockRegex.exec(markdown)) !== null) {
const docType = match[1]
const docName = match[2]
const content = match[4].trim()
if (content) {
// 将文档内容格式化为上下文,限制长度
const truncatedContent = content.length > 500 ? content.substring(0, 500) + '...' : content
lines.push(`<doc_type="${docType}" doc_name="${docName}">\n${truncatedContent}\n</doc_end>`)
}
}
if (lines.length === 0) return ''
return `\n\n-- 已上传文档内容 --\n${lines.join('\n\n')}`
}
function doFetchSuggestion(
view: EditorView,
runtime: CopilotRuntime,
@@ -306,7 +375,8 @@ function doFetchSuggestion(
const controller = new AbortController()
runtime.abortController = controller
config.fetchSuggestion(prefix, suffix, controller.signal)
const languageId = getCursorLanguageId(view)
config.fetchSuggestion(prefix, suffix, languageId, controller.signal)
.then((suggestion) => {
if (!runtime.enabled) return
if (runtime.requestSeq !== requestSeq) return
@@ -320,7 +390,7 @@ function doFetchSuggestion(
})
.catch((e: any) => {
if (e?.name !== 'AbortError') {
console.error('[Copilot] Error:', e)
// Log error silently
}
})
.finally(() => {
@@ -335,7 +405,6 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
const doc = view.state.doc
const schema = view.state.schema
const baseSize = doc.content.size
const serializer = runtime.ctx.get(serializerCtx)
let prefixMarkdown = ''
@@ -351,18 +420,26 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
if (!suffixMarkdown) {
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
}
} catch (e) {
console.error('[Copilot] Serializer error:', e)
} catch {
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
}
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
// 构建上下文:OCR内容 + 上传文档内容
const ocrContext = buildOcrContextForRequest(doc, pos)
const overLimit = totalWithOcr > SIZE_LIMIT
// 从markdown中提取文档块内容用于AI补全上下文
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
// 组合所有上下文到prefix前面
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
const totalWithContext = totalTextLen + contextLen
// 使用32KB限制(文档上下文)
const overLimit = totalWithContext > DOC_SIZE_LIMIT
if (overLimit) {
setCopilotEnabled(view, false)
@@ -379,9 +456,10 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
runtime.requestSeq = requestSeq
const requestDocVersion = runtime.docVersion
// 使用包含文档上下文的prefix
runtime.debounceTimer = setTimeout(() => {
runtime.debounceTimer = null
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion)
doFetchSuggestion(view, runtime, pos, fullPrefixWithContext, suffixMarkdown, requestSeq, requestDocVersion)
}, debounceMs)
}
+11 -18
View File
@@ -13,16 +13,14 @@ export const useSettingsStore = defineStore('settings', () => {
const debounceMs = ref(1000) // 1000 - 5000
// 3. Privacy
const privacyMode = ref(false)
const privacyMode = ref(true)
// 4. Preferences
const language = ref('auto')
const currency = ref('auto')
// const timezone = ref('auto') // removed
// 5. Background
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
// const backgroundColor = ref('#ffffff') // removed
const backgroundImage = ref('')
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
@@ -47,6 +45,11 @@ export const useSettingsStore = defineStore('settings', () => {
return translations[uiLanguage.value] || translations['en']
})
const initialMarkdown = computed(() => {
const lang = uiLanguage.value
return translations[lang]?.initialMarkdown || translations['en']?.initialMarkdown || ''
})
// --- Actions/Logic ---
// Load from localStorage
@@ -61,18 +64,15 @@ export const useSettingsStore = defineStore('settings', () => {
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.language) language.value = data.language
if (data.currency) currency.value = data.currency
// if (data.timezone) timezone.value = data.timezone // timezone legacy ignore
if (data.backgroundType) {
// migrate color to default if needed, or mapped
if (data.backgroundType === 'color') backgroundType.value = 'default'
else backgroundType.value = data.backgroundType
}
// if (data.backgroundColor) backgroundColor.value = data.backgroundColor // removed
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
}
} catch (e) {
console.error('Failed to load settings', e)
} catch {
// Failed to load settings, use defaults
}
}
@@ -86,15 +86,13 @@ export const useSettingsStore = defineStore('settings', () => {
privacyMode: privacyMode.value,
language: language.value,
currency: currency.value,
// timezone: timezone.value, // removed
backgroundType: backgroundType.value,
// backgroundColor: backgroundColor.value, // removed
backgroundImage: backgroundImage.value,
backgroundOpacity: backgroundOpacity.value,
}
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
} catch (e) {
console.error('Failed to save settings', e)
} catch {
// Failed to save settings
}
}
@@ -106,9 +104,7 @@ export const useSettingsStore = defineStore('settings', () => {
privacyMode.value = false
language.value = 'auto'
currency.value = 'auto'
// timezone.value = 'auto' // removed
backgroundType.value = 'default'
// backgroundColor.value = '#ffffff' // removed
backgroundImage.value = ''
backgroundOpacity.value = 0.2
saveSettings()
@@ -123,9 +119,7 @@ export const useSettingsStore = defineStore('settings', () => {
privacyMode,
language,
currency,
// timezone, // removed
backgroundType,
// backgroundColor, // removed
backgroundImage,
backgroundOpacity,
],
@@ -144,13 +138,12 @@ export const useSettingsStore = defineStore('settings', () => {
privacyMode,
language,
currency,
// timezone, // removed
backgroundType,
// backgroundColor, // removed
backgroundImage,
backgroundOpacity,
uiLanguage,
t,
initialMarkdown,
resetSettings
}
})
+13 -29
View File
@@ -1,10 +1,6 @@
import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
const API_KEY = 'your-secret-key-here'
let cachedIP = null
function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
@@ -34,33 +30,28 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
reason,
}),
})
} catch (e) {
console.debug('[Copilot] cancel request failed', e)
}
}
async function getClientIP() {
if (cachedIP) return cachedIP
try {
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000)
const res = await fetch('https://api.ipify.org?format=json', { signal: controller.signal })
const data = await res.json()
cachedIP = data.ip
return cachedIP
} catch {
return null
// Cancel request failed silently
}
}
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
normalizedLanguageId = languageId.trim()
} else if (languageId && typeof languageId === 'object' && 'aborted' in languageId) {
signal = languageId
}
if (typeof signal === 'string') {
apiUrl = signal
signal = undefined
}
const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl)
@@ -79,22 +70,15 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
try {
const settings = useSettingsStore()
const clientIP = await getClientIP()
const headers = {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'X-Request-Id': requestId,
}
// Only send IP if privacy mode is OFF
if (clientIP && !settings.privacyMode) {
headers['X-Client-IP'] = clientIP
}
const body = {
prefix,
suffix,
languageId: 'markdown',
languageId: normalizedLanguageId,
model_thinking: settings.modelThinking,
privacy_mode: settings.privacyMode,
user_preferences: {
+2 -1
View File
@@ -1,6 +1,7 @@
export const DEBUG = import.meta.env.DEV
export const DEBUG = import.meta.env.DEV
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
+43
View File
@@ -0,0 +1,43 @@
import { CONVERT_URL } from './config.js'
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
const splitIndex = dataUrl.indexOf(',')
if (splitIndex === -1) {
reject(new Error('Invalid file data'))
return
}
resolve(dataUrl.slice(splitIndex + 1))
}
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
export async function convertFileToMarkdown(file) {
const base64 = await readFileAsBase64(file)
const res = await fetch(CONVERT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file: base64,
filename: file.name || 'document',
}),
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
const data = await res.json()
if (!data || typeof data.markdown !== 'string') {
throw new Error('No markdown returned')
}
return data.markdown
}
+52 -6
View File
@@ -34,13 +34,24 @@ export const translations = {
exportDocx: 'Export DOCX',
exportPdf: 'Export PDF',
uploadImg: 'Upload Image',
uploadFile: 'Upload File',
uploadDoc: 'Upload Document',
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'Enable AI',
disableAI: 'Disable AI',
insertUrl: 'Insert Image from URL',
insert: 'Insert',
cancel: 'Cancel',
imgTooLarge: 'Image too large',
docTooLarge: 'Document too large, AI disabled'
docTooLarge: 'Document too large, AI disabled',
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...'
},
zh: {
settings: '设置',
@@ -77,13 +88,24 @@ export const translations = {
exportDocx: '导出 DOCX',
exportPdf: '导出 PDF',
uploadImg: '上传图片',
uploadFile: '上传文件',
uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。',
uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败',
enableAI: '启用 AI',
disableAI: '禁用 AI',
insertUrl: '通过 URL 插入图片',
insert: '插入',
cancel: '取消',
imgTooLarge: '图片过大',
docTooLarge: '文档过大,AI已禁用'
docTooLarge: '文档过大,AI已禁用',
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...'
},
ja: {
settings: '設定',
@@ -120,13 +142,19 @@ export const translations = {
exportDocx: 'DOCXをエクスポート',
exportPdf: 'PDFをエクスポート',
uploadImg: '画像をアップロード',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化',
insertUrl: 'URLから画像を挿入',
insert: '挿入',
cancel: 'キャンセル',
imgTooLarge: '画像が大きすぎます',
docTooLarge: 'ドキュメントが大きすぎます、AI無効'
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...'
},
ko: {
settings: '설정',
@@ -163,13 +191,19 @@ export const translations = {
exportDocx: 'DOCX 내보내기',
exportPdf: 'PDF 내보내기',
uploadImg: '이미지 업로드',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'AI 활성화',
disableAI: 'AI 비활성화',
insertUrl: 'URL로 이미지 삽입',
insert: '삽입',
cancel: '취소',
imgTooLarge: '이미지가 너무 큽니다',
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨'
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...'
},
de: {
settings: 'Einstellungen',
@@ -206,13 +240,19 @@ export const translations = {
exportDocx: 'DOCX exportieren',
exportPdf: 'PDF exportieren',
uploadImg: 'Bild hochladen',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'KI aktivieren',
disableAI: 'KI deaktivieren',
insertUrl: 'Bild per URL einfügen',
insert: 'Einfügen',
cancel: 'Abbrechen',
imgTooLarge: 'Bild zu groß',
docTooLarge: 'Dokument zu groß, KI deaktiviert'
docTooLarge: 'Dokument zu groß, KI deaktiviert',
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...'
},
fr: {
settings: 'Paramètres',
@@ -249,12 +289,18 @@ export const translations = {
exportDocx: 'Exporter DOCX',
exportPdf: 'Exporter PDF',
uploadImg: 'Télécharger image',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'Activer IA',
disableAI: 'Désactiver IA',
insertUrl: 'Insérer image via URL',
insert: 'Insérer',
cancel: 'Annuler',
imgTooLarge: 'Image trop grande',
docTooLarge: 'Document trop grand, IA désactivée'
docTooLarge: 'Document trop grand, IA désactivée',
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...'
}
}