Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05b1cbf80d | |||
| ef162de168 | |||
| 1155de4867 | |||
| d452d1747e | |||
| c0d4bf8b2b | |||
| 8d89c2a0f6 |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"label": "Run",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"icon": "play"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE_URL=http://149.104.29.239:8001
|
VITE_API_BASE_URL=
|
||||||
VITE_API_URL=http://149.104.29.239:8001/v1/completions
|
VITE_API_URL=
|
||||||
VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr
|
VITE_OCR_URL=
|
||||||
|
VITE_CONVERT_URL=
|
||||||
|
|||||||
@@ -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 提出改进。
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
@@ -9,9 +9,14 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
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')
|
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)
|
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||||
logger = logging.getLogger("llm")
|
logger = logging.getLogger("llm")
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ async def call_ollama(
|
|||||||
if thinking:
|
if thinking:
|
||||||
kwargs["think"] = thinking
|
kwargs["think"] = thinking
|
||||||
|
|
||||||
response = await client.chat(**kwargs)
|
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
end_dt = datetime.now()
|
end_dt = datetime.now()
|
||||||
@@ -156,15 +161,18 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.chat(
|
response = await asyncio.wait_for(
|
||||||
model=VLM_MODEL,
|
client.chat(
|
||||||
messages=[{
|
model=VLM_MODEL,
|
||||||
'role': 'user',
|
messages=[{
|
||||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
'role': 'user',
|
||||||
'images': [image_bytes]
|
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||||
}],
|
'images': [image_bytes]
|
||||||
stream=False,
|
}],
|
||||||
options={'temperature': 0.3}
|
stream=False,
|
||||||
|
options={'temperature': 0.3}
|
||||||
|
),
|
||||||
|
timeout=OCR_TIMEOUT
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
|
|||||||
+144
-43
@@ -2,18 +2,19 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Security
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from fastapi.security import APIKeyHeader
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from geoip import get_ip_location_text
|
|
||||||
from llm import call_ollama, call_vlm_ocr
|
from llm import call_ollama, call_vlm_ocr
|
||||||
from prompt import build_completion_prompts, prepare_prompt_context
|
from prompt import build_completion_prompts, prepare_prompt_context
|
||||||
|
import markitdown
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -23,28 +24,33 @@ logger = logging.getLogger("api")
|
|||||||
|
|
||||||
app = FastAPI()
|
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: dict[str, asyncio.Task] = {}
|
||||||
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
app.add_middleware(
|
# Rate limiting
|
||||||
CORSMiddleware,
|
MAX_CONCURRENT_COMPLETIONS = 4
|
||||||
allow_origins=["*"],
|
COMPLETION_RATE_LIMIT = 60 # per minute
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
API_KEY = "your-secret-key-here"
|
# File size limits (bytes)
|
||||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
MAX_CONVERT_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
# Allowed file extensions
|
||||||
async def get_api_key(api_key: str = Security(api_key_header)):
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
if api_key != API_KEY:
|
ALLOWED_CONVERT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"}
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
)
|
|
||||||
return api_key
|
|
||||||
|
|
||||||
|
|
||||||
class UserPreferences(BaseModel):
|
class UserPreferences(BaseModel):
|
||||||
@@ -73,6 +79,11 @@ class OCRRequest(BaseModel):
|
|||||||
language: str = "auto"
|
language: str = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertRequest(BaseModel):
|
||||||
|
file: str
|
||||||
|
filename: str = "document.pdf"
|
||||||
|
|
||||||
|
|
||||||
def _preview(text: str, limit: int = 80) -> str:
|
def _preview(text: str, limit: int = 80) -> str:
|
||||||
value = (text or "").replace("\n", "\\n")
|
value = (text or "").replace("\n", "\\n")
|
||||||
if len(value) <= limit:
|
if len(value) <= limit:
|
||||||
@@ -80,37 +91,34 @@ def _preview(text: str, limit: int = 80) -> str:
|
|||||||
return value[:limit] + "..."
|
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:
|
def _sse_payload(payload: dict) -> str:
|
||||||
return f"data: {json.dumps(payload)}\n\n"
|
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")
|
@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_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||||
request_tag = request_id[:8]
|
request_tag = request_id[:8]
|
||||||
inference_task: Optional[asyncio.Task] = None
|
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:
|
try:
|
||||||
logger.info(
|
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_tag,
|
||||||
request_id,
|
request_id,
|
||||||
client_ip,
|
|
||||||
len(req.prefix or ""),
|
len(req.prefix or ""),
|
||||||
len(req.suffix or ""),
|
len(req.suffix or ""),
|
||||||
req.languageId,
|
req.languageId,
|
||||||
@@ -126,7 +134,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
|||||||
req.prefix,
|
req.prefix,
|
||||||
req.suffix,
|
req.suffix,
|
||||||
req.languageId,
|
req.languageId,
|
||||||
location=location,
|
|
||||||
thinking_level=req.model_thinking,
|
thinking_level=req.model_thinking,
|
||||||
preferences=req.user_preferences,
|
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")
|
return StreamingResponse(cancelled(), media_type="text/event-stream")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, 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:
|
finally:
|
||||||
async with ACTIVE_COMPLETIONS_LOCK:
|
async with ACTIVE_COMPLETIONS_LOCK:
|
||||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
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")
|
@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_tag = str(uuid.uuid4())[:8]
|
||||||
request_id = req.request_id or ""
|
request_id = req.request_id or ""
|
||||||
|
|
||||||
@@ -218,7 +225,7 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/v1/ocr")
|
@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]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -228,7 +235,22 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
|||||||
request.language,
|
request.language,
|
||||||
len(request.image or ""),
|
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)
|
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))
|
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||||
result = await call_vlm_ocr(image_bytes, request.language)
|
result = await call_vlm_ocr(image_bytes, request.language)
|
||||||
logger.info(
|
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}
|
return {"text": result, "filename": request.filename}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[%s] /v1/ocr failed: %s", request_id, 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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
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
@@ -127,8 +127,205 @@ def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]:
|
|||||||
return _prepare_context(prefix, suffix)
|
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:
|
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.
|
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.
|
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.
|
- If you output any math expression, it must be strict KaTeX-compatible math.
|
||||||
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
|
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
|
||||||
- Never output bare formulas without $ or $$ wrappers.
|
- 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:
|
3) Strict code formatting:
|
||||||
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
|
- 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:...>.
|
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
|
||||||
- Never output any OCR tag.
|
- Never output any OCR tag.
|
||||||
- Never output OCR tag fragments such as <OCR:...>."""
|
- 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()
|
return system_prompt.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -319,7 +522,7 @@ def build_completion_prompts(
|
|||||||
thinking_level: str = "low",
|
thinking_level: str = "low",
|
||||||
preferences: object = None,
|
preferences: object = None,
|
||||||
) -> Tuple[str, str]:
|
) -> 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, recent_suffix = _prepare_context(prefix, suffix)
|
||||||
recent_prefix = _normalize_newlines(recent_prefix)
|
recent_prefix = _normalize_newlines(recent_prefix)
|
||||||
recent_suffix = _normalize_newlines(recent_suffix)
|
recent_suffix = _normalize_newlines(recent_suffix)
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ pydantic
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
geoip2
|
geoip2
|
||||||
|
markitdown[all]
|
||||||
|
python-docx
|
||||||
|
python-pptx
|
||||||
|
openpyxl
|
||||||
|
pypdf
|
||||||
|
|||||||
Generated
+1020
-877
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "echo 'No tests configured yet'",
|
||||||
|
"check": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@milkdown/core": "^7.18.0",
|
"@milkdown/core": "^7.18.0",
|
||||||
|
|||||||
+3
-4
@@ -17,16 +17,15 @@ function onChange(markdownValue) {
|
|||||||
const appStyle = computed(() => {
|
const appStyle = computed(() => {
|
||||||
const style = {}
|
const style = {}
|
||||||
if (settings.backgroundType === 'warm') {
|
if (settings.backgroundType === 'warm') {
|
||||||
style.background = '#fdfaf3ff' // Very light warm yellow
|
style.background = '#fdfaf3ff'
|
||||||
style.color = '#5d4037'
|
style.color = '#5d4037'
|
||||||
} else if (settings.backgroundType === 'reading') {
|
} else if (settings.backgroundType === 'reading') {
|
||||||
style.background = '#f5f0e1' // Slightly deeper warm paper color
|
style.background = '#f5f0e1'
|
||||||
style.color = '#333333'
|
style.color = '#333333'
|
||||||
} else if (settings.backgroundType === 'default') {
|
} else if (settings.backgroundType === 'default') {
|
||||||
style.background = 'var(--app-bg)'
|
style.background = 'var(--app-bg)'
|
||||||
style.color = 'var(--app-text)'
|
style.color = 'var(--app-text)'
|
||||||
} else if (settings.backgroundType === 'image') {
|
} else if (settings.backgroundType === 'image') {
|
||||||
// For image background, we might want a solid base color underlying it
|
|
||||||
style.background = settings.theme === 'dark' ? '#000000' : '#ffffff'
|
style.background = settings.theme === 'dark' ? '#000000' : '#ffffff'
|
||||||
}
|
}
|
||||||
return style
|
return style
|
||||||
@@ -69,7 +68,7 @@ const backgroundStyle = computed(() => {
|
|||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
transition: background 0.3s, color 0.3s;
|
transition: background 0.3s, color 0.3s;
|
||||||
isolation: isolate; /* Create new stacking context */
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-container {
|
.editor-container {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true
|
typographer: true
|
||||||
})
|
})
|
||||||
|
|||||||
+531
-577
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,13 @@
|
|||||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import packageJson from '../../package.json'
|
||||||
|
|
||||||
const store = useSettingsStore()
|
const store = useSettingsStore()
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
|
const VERSION = packageJson.version || '0.0.0'
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
let systemThemeMediaQuery = null
|
let systemThemeMediaQuery = null
|
||||||
|
|
||||||
@@ -113,7 +116,6 @@ const appearanceMode = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Background Image Handling
|
|
||||||
const handleImageUpload = (event) => {
|
const handleImageUpload = (event) => {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -126,7 +128,6 @@ const handleImageUpload = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to translate
|
|
||||||
const t = (key) => store.t[key]
|
const t = (key) => store.t[key]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -274,7 +275,7 @@ const t = (key) => store.t[key]
|
|||||||
<div class="about-card">
|
<div class="about-card">
|
||||||
<h4>llm-in-text</h4>
|
<h4>llm-in-text</h4>
|
||||||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -10,10 +10,10 @@ const app = createApp(App)
|
|||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch((error) => {
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
console.error('Service worker registration failed:', error)
|
// Service worker registration failed, silently ignore
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
|
|||||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||||
const DEBOUNCE_MS = 1000
|
const DEBOUNCE_MS = 1000
|
||||||
const SIZE_LIMIT = OCR_SIZE_LIMIT
|
const SIZE_LIMIT = OCR_SIZE_LIMIT
|
||||||
|
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
|
||||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||||
|
|
||||||
interface CopilotState {
|
interface CopilotState {
|
||||||
@@ -18,7 +19,7 @@ interface CopilotState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CopilotConfig {
|
interface CopilotConfig {
|
||||||
fetchSuggestion: (prefix: string, suffix: string, signal?: AbortSignal) => Promise<string>
|
fetchSuggestion: (prefix: string, suffix: string, languageId: string, signal?: AbortSignal) => Promise<string>
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ function normalizeSuggestionText(raw: string): string {
|
|||||||
text = parsed.replace(/\r\n?/g, '\n')
|
text = parsed.replace(/\r\n?/g, '\n')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep original text when not valid JSON.
|
// Not valid JSON, keep original
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,13 +188,58 @@ function normalizeSuggestionText(raw: string): string {
|
|||||||
return text
|
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) {
|
async function insertGhostText(view: EditorView, suggestion: string, from: number, ctx: Ctx) {
|
||||||
if (!suggestion) return
|
if (!suggestion) return
|
||||||
|
|
||||||
const markType = view.state.schema.marks.copilot_ghost
|
const markType = view.state.schema.marks.copilot_ghost
|
||||||
|
|
||||||
if (!markType) {
|
if (!markType) {
|
||||||
console.error('[Copilot] copilot_ghost mark not found in schema')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +256,6 @@ async function insertGhostText(view: EditorView, suggestion: string, from: numbe
|
|||||||
const insertedRange = insertParsedMarkdownSlice(tr, from, parsedDoc)
|
const insertedRange = insertParsedMarkdownSlice(tr, from, parsedDoc)
|
||||||
|
|
||||||
if (!insertedRange) {
|
if (!insertedRange) {
|
||||||
console.warn('[Copilot] parsed markdown insertion failed, falling back to plain text')
|
|
||||||
insertPlainText(view, suggestion, from, markType)
|
insertPlainText(view, suggestion, from, markType)
|
||||||
return
|
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.setSelection(Selection.near(tr.doc.resolve(getCursorBeforeGhostInsert(tr, from)), -1))
|
||||||
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertedRange.from, to: insertedRange.to, suggestion })
|
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertedRange.from, to: insertedRange.to, suggestion })
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('[Copilot] Parser error:', e)
|
|
||||||
insertPlainText(view, suggestion, from, markType)
|
insertPlainText(view, suggestion, from, markType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +331,31 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
|||||||
return `\n\n${lines.join('\n')}`
|
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(
|
function doFetchSuggestion(
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
runtime: CopilotRuntime,
|
runtime: CopilotRuntime,
|
||||||
@@ -306,7 +375,8 @@ function doFetchSuggestion(
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
runtime.abortController = controller
|
runtime.abortController = controller
|
||||||
|
|
||||||
config.fetchSuggestion(prefix, suffix, controller.signal)
|
const languageId = getCursorLanguageId(view)
|
||||||
|
config.fetchSuggestion(prefix, suffix, languageId, controller.signal)
|
||||||
.then((suggestion) => {
|
.then((suggestion) => {
|
||||||
if (!runtime.enabled) return
|
if (!runtime.enabled) return
|
||||||
if (runtime.requestSeq !== requestSeq) return
|
if (runtime.requestSeq !== requestSeq) return
|
||||||
@@ -320,7 +390,7 @@ function doFetchSuggestion(
|
|||||||
})
|
})
|
||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
console.error('[Copilot] Error:', e)
|
// Log error silently
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -335,7 +405,6 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
|||||||
|
|
||||||
const doc = view.state.doc
|
const doc = view.state.doc
|
||||||
const schema = view.state.schema
|
const schema = view.state.schema
|
||||||
const baseSize = doc.content.size
|
|
||||||
|
|
||||||
const serializer = runtime.ctx.get(serializerCtx)
|
const serializer = runtime.ctx.get(serializerCtx)
|
||||||
let prefixMarkdown = ''
|
let prefixMarkdown = ''
|
||||||
@@ -351,18 +420,26 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
|||||||
if (!suffixMarkdown) {
|
if (!suffixMarkdown) {
|
||||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('[Copilot] Serializer error:', e)
|
|
||||||
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
|
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
|
||||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
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 totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
||||||
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
|
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
|
||||||
const totalWithOcr = totalTextLen + ocrContextLen
|
const totalWithContext = totalTextLen + contextLen
|
||||||
|
|
||||||
const overLimit = totalWithOcr > SIZE_LIMIT
|
// 使用32KB限制(文档上下文)
|
||||||
|
const overLimit = totalWithContext > DOC_SIZE_LIMIT
|
||||||
|
|
||||||
if (overLimit) {
|
if (overLimit) {
|
||||||
setCopilotEnabled(view, false)
|
setCopilotEnabled(view, false)
|
||||||
@@ -379,9 +456,10 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
|||||||
runtime.requestSeq = requestSeq
|
runtime.requestSeq = requestSeq
|
||||||
const requestDocVersion = runtime.docVersion
|
const requestDocVersion = runtime.docVersion
|
||||||
|
|
||||||
|
// 使用包含文档上下文的prefix
|
||||||
runtime.debounceTimer = setTimeout(() => {
|
runtime.debounceTimer = setTimeout(() => {
|
||||||
runtime.debounceTimer = null
|
runtime.debounceTimer = null
|
||||||
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion)
|
doFetchSuggestion(view, runtime, pos, fullPrefixWithContext, suffixMarkdown, requestSeq, requestDocVersion)
|
||||||
}, debounceMs)
|
}, debounceMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-18
@@ -13,16 +13,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const debounceMs = ref(1000) // 1000 - 5000
|
const debounceMs = ref(1000) // 1000 - 5000
|
||||||
|
|
||||||
// 3. Privacy
|
// 3. Privacy
|
||||||
const privacyMode = ref(false)
|
const privacyMode = ref(true)
|
||||||
|
|
||||||
// 4. Preferences
|
// 4. Preferences
|
||||||
const language = ref('auto')
|
const language = ref('auto')
|
||||||
const currency = ref('auto')
|
const currency = ref('auto')
|
||||||
// const timezone = ref('auto') // removed
|
|
||||||
|
|
||||||
// 5. Background
|
// 5. Background
|
||||||
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
||||||
// const backgroundColor = ref('#ffffff') // removed
|
|
||||||
const backgroundImage = ref('')
|
const backgroundImage = ref('')
|
||||||
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
||||||
|
|
||||||
@@ -47,6 +45,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
return translations[uiLanguage.value] || translations['en']
|
return translations[uiLanguage.value] || translations['en']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initialMarkdown = computed(() => {
|
||||||
|
const lang = uiLanguage.value
|
||||||
|
return translations[lang]?.initialMarkdown || translations['en']?.initialMarkdown || ''
|
||||||
|
})
|
||||||
|
|
||||||
// --- Actions/Logic ---
|
// --- Actions/Logic ---
|
||||||
|
|
||||||
// Load from localStorage
|
// Load from localStorage
|
||||||
@@ -61,18 +64,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
|
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
|
||||||
if (data.language) language.value = data.language
|
if (data.language) language.value = data.language
|
||||||
if (data.currency) currency.value = data.currency
|
if (data.currency) currency.value = data.currency
|
||||||
// if (data.timezone) timezone.value = data.timezone // timezone legacy ignore
|
|
||||||
if (data.backgroundType) {
|
if (data.backgroundType) {
|
||||||
// migrate color to default if needed, or mapped
|
|
||||||
if (data.backgroundType === 'color') backgroundType.value = 'default'
|
if (data.backgroundType === 'color') backgroundType.value = 'default'
|
||||||
else backgroundType.value = data.backgroundType
|
else backgroundType.value = data.backgroundType
|
||||||
}
|
}
|
||||||
// if (data.backgroundColor) backgroundColor.value = data.backgroundColor // removed
|
|
||||||
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
||||||
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('Failed to load settings', e)
|
// Failed to load settings, use defaults
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,15 +86,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
privacyMode: privacyMode.value,
|
privacyMode: privacyMode.value,
|
||||||
language: language.value,
|
language: language.value,
|
||||||
currency: currency.value,
|
currency: currency.value,
|
||||||
// timezone: timezone.value, // removed
|
|
||||||
backgroundType: backgroundType.value,
|
backgroundType: backgroundType.value,
|
||||||
// backgroundColor: backgroundColor.value, // removed
|
|
||||||
backgroundImage: backgroundImage.value,
|
backgroundImage: backgroundImage.value,
|
||||||
backgroundOpacity: backgroundOpacity.value,
|
backgroundOpacity: backgroundOpacity.value,
|
||||||
}
|
}
|
||||||
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
|
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('Failed to save settings', e)
|
// Failed to save settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +104,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
privacyMode.value = false
|
privacyMode.value = false
|
||||||
language.value = 'auto'
|
language.value = 'auto'
|
||||||
currency.value = 'auto'
|
currency.value = 'auto'
|
||||||
// timezone.value = 'auto' // removed
|
|
||||||
backgroundType.value = 'default'
|
backgroundType.value = 'default'
|
||||||
// backgroundColor.value = '#ffffff' // removed
|
|
||||||
backgroundImage.value = ''
|
backgroundImage.value = ''
|
||||||
backgroundOpacity.value = 0.2
|
backgroundOpacity.value = 0.2
|
||||||
saveSettings()
|
saveSettings()
|
||||||
@@ -123,9 +119,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
privacyMode,
|
privacyMode,
|
||||||
language,
|
language,
|
||||||
currency,
|
currency,
|
||||||
// timezone, // removed
|
|
||||||
backgroundType,
|
backgroundType,
|
||||||
// backgroundColor, // removed
|
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
backgroundOpacity,
|
backgroundOpacity,
|
||||||
],
|
],
|
||||||
@@ -144,13 +138,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
privacyMode,
|
privacyMode,
|
||||||
language,
|
language,
|
||||||
currency,
|
currency,
|
||||||
// timezone, // removed
|
|
||||||
backgroundType,
|
backgroundType,
|
||||||
// backgroundColor, // removed
|
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
backgroundOpacity,
|
backgroundOpacity,
|
||||||
uiLanguage,
|
uiLanguage,
|
||||||
t,
|
t,
|
||||||
|
initialMarkdown,
|
||||||
resetSettings
|
resetSettings
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+13
-29
@@ -1,10 +1,6 @@
|
|||||||
import { API_URL } from './config.js'
|
import { API_URL } from './config.js'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
const API_KEY = 'your-secret-key-here'
|
|
||||||
|
|
||||||
let cachedIP = null
|
|
||||||
|
|
||||||
function generateRequestId() {
|
function generateRequestId() {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
@@ -34,33 +30,28 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-API-Key': API_KEY,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
reason,
|
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 {
|
} 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 requestId = generateRequestId()
|
||||||
const cancelUrl = getCancelUrl(apiUrl)
|
const cancelUrl = getCancelUrl(apiUrl)
|
||||||
|
|
||||||
@@ -79,22 +70,15 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const clientIP = await getClientIP()
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-API-Key': API_KEY,
|
|
||||||
'X-Request-Id': requestId,
|
'X-Request-Id': requestId,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only send IP if privacy mode is OFF
|
|
||||||
if (clientIP && !settings.privacyMode) {
|
|
||||||
headers['X-Client-IP'] = clientIP
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
prefix,
|
prefix,
|
||||||
suffix,
|
suffix,
|
||||||
languageId: 'markdown',
|
languageId: normalizedLanguageId,
|
||||||
model_thinking: settings.modelThinking,
|
model_thinking: settings.modelThinking,
|
||||||
privacy_mode: settings.privacyMode,
|
privacy_mode: settings.privacyMode,
|
||||||
user_preferences: {
|
user_preferences: {
|
||||||
|
|||||||
+2
-1
@@ -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'
|
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 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 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`
|
||||||
|
|||||||
@@ -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
@@ -34,13 +34,24 @@ export const translations = {
|
|||||||
exportDocx: 'Export DOCX',
|
exportDocx: 'Export DOCX',
|
||||||
exportPdf: 'Export PDF',
|
exportPdf: 'Export PDF',
|
||||||
uploadImg: 'Upload Image',
|
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',
|
enableAI: 'Enable AI',
|
||||||
disableAI: 'Disable AI',
|
disableAI: 'Disable AI',
|
||||||
insertUrl: 'Insert Image from URL',
|
insertUrl: 'Insert Image from URL',
|
||||||
insert: 'Insert',
|
insert: 'Insert',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
imgTooLarge: 'Image too large',
|
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: {
|
zh: {
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
@@ -77,13 +88,24 @@ export const translations = {
|
|||||||
exportDocx: '导出 DOCX',
|
exportDocx: '导出 DOCX',
|
||||||
exportPdf: '导出 PDF',
|
exportPdf: '导出 PDF',
|
||||||
uploadImg: '上传图片',
|
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',
|
enableAI: '启用 AI',
|
||||||
disableAI: '禁用 AI',
|
disableAI: '禁用 AI',
|
||||||
insertUrl: '通过 URL 插入图片',
|
insertUrl: '通过 URL 插入图片',
|
||||||
insert: '插入',
|
insert: '插入',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
imgTooLarge: '图片过大',
|
imgTooLarge: '图片过大',
|
||||||
docTooLarge: '文档过大,AI已禁用'
|
docTooLarge: '文档过大,AI已禁用',
|
||||||
|
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...'
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
settings: '設定',
|
settings: '設定',
|
||||||
@@ -120,13 +142,19 @@ export const translations = {
|
|||||||
exportDocx: 'DOCXをエクスポート',
|
exportDocx: 'DOCXをエクスポート',
|
||||||
exportPdf: 'PDFをエクスポート',
|
exportPdf: 'PDFをエクスポート',
|
||||||
uploadImg: '画像をアップロード',
|
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を有効化',
|
enableAI: 'AIを有効化',
|
||||||
disableAI: 'AIを無効化',
|
disableAI: 'AIを無効化',
|
||||||
insertUrl: 'URLから画像を挿入',
|
insertUrl: 'URLから画像を挿入',
|
||||||
insert: '挿入',
|
insert: '挿入',
|
||||||
cancel: 'キャンセル',
|
cancel: 'キャンセル',
|
||||||
imgTooLarge: '画像が大きすぎます',
|
imgTooLarge: '画像が大きすぎます',
|
||||||
docTooLarge: 'ドキュメントが大きすぎます、AI無効'
|
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
|
||||||
|
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...'
|
||||||
},
|
},
|
||||||
ko: {
|
ko: {
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
@@ -163,13 +191,19 @@ export const translations = {
|
|||||||
exportDocx: 'DOCX 내보내기',
|
exportDocx: 'DOCX 내보내기',
|
||||||
exportPdf: 'PDF 내보내기',
|
exportPdf: 'PDF 내보내기',
|
||||||
uploadImg: '이미지 업로드',
|
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 활성화',
|
enableAI: 'AI 활성화',
|
||||||
disableAI: 'AI 비활성화',
|
disableAI: 'AI 비활성화',
|
||||||
insertUrl: 'URL로 이미지 삽입',
|
insertUrl: 'URL로 이미지 삽입',
|
||||||
insert: '삽입',
|
insert: '삽입',
|
||||||
cancel: '취소',
|
cancel: '취소',
|
||||||
imgTooLarge: '이미지가 너무 큽니다',
|
imgTooLarge: '이미지가 너무 큽니다',
|
||||||
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨'
|
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
|
||||||
|
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...'
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
@@ -206,13 +240,19 @@ export const translations = {
|
|||||||
exportDocx: 'DOCX exportieren',
|
exportDocx: 'DOCX exportieren',
|
||||||
exportPdf: 'PDF exportieren',
|
exportPdf: 'PDF exportieren',
|
||||||
uploadImg: 'Bild hochladen',
|
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',
|
enableAI: 'KI aktivieren',
|
||||||
disableAI: 'KI deaktivieren',
|
disableAI: 'KI deaktivieren',
|
||||||
insertUrl: 'Bild per URL einfügen',
|
insertUrl: 'Bild per URL einfügen',
|
||||||
insert: 'Einfügen',
|
insert: 'Einfügen',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
imgTooLarge: 'Bild zu groß',
|
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: {
|
fr: {
|
||||||
settings: 'Paramètres',
|
settings: 'Paramètres',
|
||||||
@@ -249,12 +289,18 @@ export const translations = {
|
|||||||
exportDocx: 'Exporter DOCX',
|
exportDocx: 'Exporter DOCX',
|
||||||
exportPdf: 'Exporter PDF',
|
exportPdf: 'Exporter PDF',
|
||||||
uploadImg: 'Télécharger image',
|
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',
|
enableAI: 'Activer IA',
|
||||||
disableAI: 'Désactiver IA',
|
disableAI: 'Désactiver IA',
|
||||||
insertUrl: 'Insérer image via URL',
|
insertUrl: 'Insérer image via URL',
|
||||||
insert: 'Insérer',
|
insert: 'Insérer',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
imgTooLarge: 'Image trop grande',
|
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...'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user