chore: 更新项目配置和前后端代码优化

This commit is contained in:
2026-04-04 20:10:24 +08:00
parent ef162de168
commit 05b1cbf80d
21 changed files with 3460 additions and 984 deletions

View File

@@ -0,0 +1,9 @@
{
"shortcuts": [
{
"label": "Run",
"command": "npm run dev",
"icon": "play"
}
]
}

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
AGENTS.md Normal file
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 提出改进。

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)

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 收集
- 原始异常泄漏
然而,这一进展并不意味着已准备好投入生产。
当前仓库展示了有用的加固工作,但仍然缺乏生产服务预期的运维、测试、节流、部署和可观测性基线。

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

View File

@@ -7,13 +7,11 @@ 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
@@ -26,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):
@@ -88,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,
@@ -134,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,
)
@@ -181,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)
@@ -190,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 ""
@@ -226,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(
@@ -236,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(
@@ -248,11 +262,11 @@ 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, api_key: str = Security(get_api_key)):
async def convert_to_markdown(request: ConvertRequest):
"""将文件转换为Markdown格式"""
request_id = str(uuid.uuid4())[:8]
@@ -264,12 +278,23 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
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)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# 获取文件扩展名
ext = os.path.splitext(request.filename)[1].lower()
# 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:
@@ -300,10 +325,26 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
except Exception as e:
logger.exception("[%s] /v1/convert failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
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)

1907
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",

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>

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>

View File

@@ -16,7 +16,7 @@ const props = defineProps({
})
const md = new MarkdownIt({
html: true,
html: false,
linkify: true,
typographer: true
})

View File

@@ -2,6 +2,19 @@
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
<!-- 文档块渲染层 -->
<div class="doc-blocks-overlay">
<DocBlockCrepe
v-for="(doc, index) in docBlocks"
:key="index"
:docType="doc.type"
:docName="doc.name"
:uploadTime="doc.time"
:initialContent="doc.content"
@update:content="(content) => updateDocBlockContent(index, content)"
/>
</div>
<div class="history-buttons">
<button
type="button"
@@ -31,6 +44,28 @@
</button>
</div>
<!-- 左下角文档上传按钮 -->
<div class="doc-upload-buttons">
<button
type="button"
class="action-btn doc-upload-btn"
:class="{ 'force-disabled': !canInsertDoc }"
:disabled="!canInsertDoc"
:aria-label="t('uploadDoc')"
:title="!canInsertDoc ? t('uploadDocDisabled') : t('uploadDoc')"
@click="triggerDocUpload"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M12 18v-6"/>
<path d="m9 15 3 3 3-3"/>
</svg>
<span class="btn-tooltip">{{ t('uploadDoc') || '上传文档' }}</span>
</button>
<input type="file" ref="docUploadInputRef" @change="handleDocUpload" accept=".txt,.docx,.pptx,.pdf" style="display:none">
</div>
<div class="action-buttons">
<button
type="button"
@@ -45,7 +80,7 @@
</svg>
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
</button>
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept="image/*,.doc,.docx,.ppt,.pptx,.pdf,.zip,.txt,.json" style="display:none">
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept="image/*,.doc,.docx,.ppt,.pptx,.pdf,.txt,.json" style="display:none">
<button
type="button"
@@ -160,6 +195,8 @@ import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import DocumentBlock from './DocumentBlock.vue'
import DocBlockCrepe from './DocBlockCrepe.vue'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
@@ -168,6 +205,7 @@ const initialMarkdown = computed(() => settings.initialMarkdown)
const root = ref(null)
const fileInputRef = ref(null)
const docUploadInputRef = ref(null)
const uploadFileInputRef = ref(null)
const imageInputRef = ref(null)
const cameraInputRef = ref(null)
@@ -178,6 +216,9 @@ const showUrlDialog = ref(false)
const imageUrl = ref('')
const canUndo = ref(false)
const canRedo = ref(false)
const canInsertDoc = ref(true)
// 文档块列表 - 用于渲染 DocBlockCrepe 组件
const docBlocks = ref([])
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
@@ -200,7 +241,7 @@ const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const CONVERT_EXT_RE = /\.(docx?|pptx?|pdf|zip)$/i
const CONVERT_EXT_RE = /\.(docx?|pptx?|pdf)$/i
const TEXT_EXT_RE = /\.(txt|json)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json'])
const CONVERT_MIME_TYPES = new Set([
@@ -209,8 +250,6 @@ const CONVERT_MIME_TYPES = new Set([
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf',
'application/zip',
'application/x-zip-compressed',
])
let lastInitialMarkdown = initialMarkdown.value
@@ -424,7 +463,6 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here'
},
body: JSON.stringify({
image: base64,
@@ -558,6 +596,10 @@ onMounted(async () => {
refreshSizeAndLimit(ctx)
updateHistoryState(view)
scheduleMarkdownSync()
// 解析文档块并渲染
parseDocBlocks()
// 检查是否可以插入文档
checkCanInsertDoc()
})
})
@@ -578,7 +620,8 @@ const exportMarkdown = async () => {
clearCurrentSuggestion(view)
})
const markdown = await crepe.getMarkdown()
// 使用转换函数处理文档块格式
const markdown = await transformDocBlocksForExport()
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -771,6 +814,266 @@ const insertImageFromUrl = () => {
showUrlDialog.value = false
}
// 文档上传相关功能
// 检查光标是否在现有文档块内(防止重叠)
const isCursorInDocumentBlock = async () => {
if (!crepe) return false
const result = await new Promise((resolve) => {
let inDocBlock = false
crepe.editor.action(async (ctx) => {
const view = ctx.get(editorViewCtx)
const { from } = view.state.selection
const markdown = await crepe.getMarkdown()
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 startPos = match.index
const endPos = match.index + match[0].length
if (from >= startPos && from <= endPos) {
inDocBlock = true
break
}
}
resolve(inDocBlock)
})
})
return result
}
// 检测光标是否在代码块中
const isCursorInFencedCodeBlock = () => {
if (!crepe) return false
let result = false
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
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 === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
result = true
break
}
}
})
return result
}
// 检测光标是否在 mermaid 上下文中
const isCursorInMermaidContext = () => {
if (!crepe) return false
let result = false
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
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 === 'fence') {
const lang = node.attrs?.language || ''
if (lang.toLowerCase() === 'mermaid') {
result = true
break
}
}
}
})
return result
}
// 检测光标是否在 latex 块中
const isCursorInLatexBlock = () => {
if (!crepe) return false
let result = false
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
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') {
result = true
break
}
}
})
return result
}
// 检查是否可以插入文档
const checkCanInsertDoc = async () => {
if (!crepe) {
canInsertDoc.value = true
return
}
const inDocBlock = await isCursorInDocumentBlock()
const inCodeBlock = isCursorInFencedCodeBlock()
const inMermaid = isCursorInMermaidContext()
const inLatex = isCursorInLatexBlock()
canInsertDoc.value = !inDocBlock && !inCodeBlock && !inMermaid && !inLatex
}
// 更新文档块内容
const updateDocBlockContent = async (index, content) => {
if (!crepe || index < 0 || index >= docBlocks.value.length) return
docBlocks.value[index].content = content
await syncDocBlocksToMarkdown()
}
// 将文档块同步到 markdown
const syncDocBlocksToMarkdown = async () => {
if (!crepe) return
try {
const markdown = await crepe.getMarkdown()
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>\n*总字数:\d+/g
let newMarkdown = markdown.replace(docBlockRegex, (match, docType, docName, uploadTime) => {
const block = docBlocks.value.find(b => b.name === docName && b.type === docType)
if (block) {
const docTag = `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}">`
const totalChars = docTag.length + 1 + block.content.length + '</doc_end>'.length
return `${docTag}\n${block.content}\n</doc_end>\n总字数${totalChars}`
}
return match
})
if (newMarkdown !== markdown) {
crepe.editor.action(replaceAll(newMarkdown))
}
} catch (e) {
console.error('Failed to sync doc blocks:', e)
}
}
// 获取文件类型图标和名称
const getFileTypeInfo = (filename) => {
const ext = filename.toLowerCase().split('.').pop()
const typeMap = {
'txt': { icon: 'file-text', name: 'TXT 文档', code: 'text' },
'docx': { icon: 'file-word', name: 'Word 文档', code: 'doc' },
'doc': { icon: 'file-word', name: 'Word 文档', code: 'doc' },
'pptx': { icon: 'file-powerpoint', name: 'PPT 演示', code: 'ppt' },
'ppt': { icon: 'file-powerpoint', name: 'PPT 演示', code: 'ppt' },
'pdf': { icon: 'file-pdf', name: 'PDF 文档', code: 'pdf' }
}
return typeMap[ext] || { icon: 'file', name: '文档', code: 'doc' }
}
// 触发文档上传
const triggerDocUpload = () => {
docUploadInputRef.value?.click()
}
// 处理文档上传
const handleDocUpload = async (event) => {
const input = event.target
const file = input.files?.[0]
if (!file) return
if (!canInsertDoc.value) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不允许插入文档')
input.value = ''
return
}
// 检查文件大小
const MAX_FILE_SIZE = 10 * 1024 * 1024
if (file.size > MAX_FILE_SIZE) {
alert(t('uploadDocSizeWarning') || '文件大小不能超过 10MB')
input.value = ''
return
}
// 检查文件类型
const allowedExts = ['.txt', '.docx', '.pptx', '.pdf']
const ext = '.' + file.name.toLowerCase().split('.').pop()
if (!allowedExts.includes(ext)) {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
input.value = ''
return
}
try {
// 转换文件为markdown
const markdown = await convertFileToMarkdown(file)
if (!markdown) {
throw new Error('No markdown returned')
}
// 获取文件信息
const fileInfo = getFileTypeInfo(file.name)
const now = new Date()
const uploadTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
// 构建文档块格式
const escapedDocName = file.name.replace(/"/g, '&quot;').replace(/</g, '&lt;')
const docTag = `<doc_type="${fileInfo.code}" doc_name="${escapedDocName}" upload_time="${uploadTime}">`
const totalChars = docTag.length + 1 + markdown.length + '</doc_end>'.length
const docBlock = `\n${docTag}\n${markdown}\n</doc_end>\n总字数${totalChars}\n`
clearCurrentGhost()
// 在光标位置插入
insertMarkdownAtCursor(docBlock)
parseDocBlocks()
} catch (e) {
const message = e instanceof Error ? e.message : ''
alert(t('uploadDocError') || `文档转换失败: ${message}`)
} finally {
input.value = ''
}
}
// 从markdown中解析文档块
const parseDocBlocks = async () => {
if (!crepe) return
try {
const markdown = await crepe.getMarkdown()
// 使用正则表达式匹配文档块
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
const blocks = []
let match
while ((match = docBlockRegex.exec(markdown)) !== null) {
blocks.push({
type: match[1],
name: match[2],
time: match[3],
content: match[4]
})
}
docBlocks.value = blocks
} catch (e) {
console.error('Failed to parse doc blocks:', e)
}
}
// 导出时转换文档块格式
const transformDocBlocksForExport = async () => {
if (!crepe) return ''
let markdown = await crepe.getMarkdown()
// 使用正则表达式匹配文档块并转换格式
// <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>\n*总字数:\d+/g
markdown = markdown.replace(docBlockRegex, (match, docType, docName, uploadTime, content) => {
// 转换为 ```doc 格式
return `\n\`\`\`${docType}\n${content.trim()}\n\`\`\`\n`
})
return markdown
}
onUnmounted(() => {
if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer)
@@ -802,6 +1105,52 @@ onUnmounted(() => {
overflow: hidden;
}
/* 文档块渲染层 */
.doc-blocks-overlay {
position: absolute;
top: 0;
left: 40px;
right: 40px;
z-index: 100;
pointer-events: none;
}
.doc-blocks-overlay > * {
pointer-events: auto;
}
.doc-upload-buttons {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
gap: 8px;
z-index: 9999;
}
.doc-upload-btn {
width: 44px;
height: 44px;
padding: 10px;
background-color: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--panel-border);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--panel-shadow);
opacity: 0.85;
}
.doc-upload-btn:hover {
background-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
opacity: 1;
}
.history-buttons {
position: fixed;
top: calc(16px + env(safe-area-inset-top));

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
@@ -272,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>

View File

@@ -10,7 +10,7 @@ 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(() => {
// Service worker registration failed, silently ignore

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 {
@@ -330,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,
@@ -379,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 = ''
@@ -400,12 +425,21 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
}
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
// 构建上下文OCR内容 + 上传文档内容
const ocrContext = buildOcrContextForRequest(doc, pos)
// 从markdown中提取文档块内容用于AI补全上下文
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
// 组合所有上下文到prefix前面
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
const totalWithContext = totalTextLen + contextLen
const overLimit = totalWithOcr > SIZE_LIMIT
// 使用32KB限制文档上下文
const overLimit = totalWithContext > DOC_SIZE_LIMIT
if (overLimit) {
setCopilotEnabled(view, false)
@@ -422,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)
}

View File

@@ -13,7 +13,7 @@ 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')

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,7 +30,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
@@ -46,20 +41,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
}
}
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
}
}
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
@@ -89,18 +70,11 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
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,

View File

@@ -1,3 +1,5 @@
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`

View File

@@ -1,7 +1,5 @@
import { CONVERT_URL } from './config.js'
const API_KEY = 'your-secret-key-here'
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -25,7 +23,6 @@ export async function convertFileToMarkdown(file) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
file: base64,

View File

@@ -35,6 +35,11 @@ export const translations = {
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.',
@@ -84,6 +89,11 @@ export const translations = {
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: '文件上传失败',