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

This commit is contained in:
2026-04-04 20:05:40 +08:00
parent ef162de168
commit be4000b774
33 changed files with 2338 additions and 942 deletions

View File

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

View File

@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://149.104.29.239:8001
VITE_API_URL=http://149.104.29.239:8001/v1/completions
VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr
VITE_API_BASE_URL=
VITE_API_URL=
VITE_OCR_URL=
VITE_CONVERT_URL=

7
.gitignore vendored
View File

@@ -1,4 +1,4 @@
# Logs
# Logs
logs
*.log
npm-debug.log*
@@ -39,3 +39,8 @@ env/
*.njsproj
*.sln
*.sw?
# IDE directories
.kilocode/
.codex/

65
AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# rules.md
在构建这个LLM应用网页时你需要基于VUE3开发。我需要前端只运行渲染和数据回传后端负责llm api调用类似copilet的auto inline suggustions实现和数据解析。
# **重要** : 在回复用户消息时,一定要使用中文
## 指导原则
- 不要擅自用npm或者yarn运行网页你既看不到网页的内容也无法阻止命令暂停。但是你可以用npm run build检查代码。
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
- @/milkdown-docs/ 代表milkdown的最新官方文档不要修改涉及到前端编辑器的指令时要核对官方文档。
# 仓库指南
## 语言约定
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 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`)。

View File

@@ -1,4 +1,4 @@
# LLM in Text - 智能写作助手
# LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能提供类似 GitHub Copilot 的 Ghost Text 体验。

BIN
README.md.fixed Normal file

Binary file not shown.

BIN
README.md.original Normal file

Binary file not shown.

265
README.md.tmp Normal file
View File

@@ -0,0 +1,265 @@
# LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能提供类似 GitHub Copilot 的 Ghost Text 体验。
## 功能特性
### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持完整 Markdown 语法和 LaTeX 公式
- 导入/导出 Markdown 文件
### AI 智能补全
- 实时生成文本补全建议(灰色显示)
- 流式响应,低延迟体验
- 多种交互方式:
- **Tab 键**:接受建议
- **Esc 键**:拒绝建议
- **点击灰色文本**:接受建议
- **继续输入**:自动拒绝建议
### AI 开关控制
- 右下角 AI 开关按钮
- 白色 = AI 启用,黑色 = AI 禁用
- 禁用时自动清除灰色文本并停止 API 调用
## 技术架构
```mermaid
flowchart TB
subgraph Frontend["前端 (Vue3 + Vite)"]
A[App.vue] --> B[MilkdownEditor.vue]
B --> C[Crepe Editor]
C --> D[ProseMirror]
D --> E[copilotPlugin.ts]
E --> F[copilotGhostMark]
E --> G[api.js]
end
subgraph Backend["后端 (FastAPI + Python)"]
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
H --> J[llm.py<br/>Ollama 调用]
J --> K[Ollama API]
end
G -->|POST /v1/completions<br/>SSE 流式响应| H
K -->|LLM 响应| J
```
## 项目结构
```
llm-in-text/
├── src/
│ ├── components/
│ │ └── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
│ │ ├── types.ts # 类型定义
│ │ └── index.ts # 插件导出
│ ├── utils/
│ │ ├── api.js # API 调用封装
│ │ ├── config.js # 配置文件
│ │ └── ocrCache.js # OCR 缓存管理
│ ├── App.vue
│ └── main.js
├── backend/
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
│ └── requirements.txt
└── README.md
```
## 快速开始
### 环境要求
- Node.js 18+
- Python 3.8+
- Ollama 服务(或其他兼容 OpenAI API 的服务)
### 安装
```bash
# 前端
npm install
# 后端
cd backend
pip install -r requirements.txt
```
### 配置
在 `backend/.env` 中配置:
```env
OLLAMA_MODEL=gpt-oss:20b
OLLAMA_HOST=http://localhost:11434
```
### 启动
```bash
# 后端(端口 8000
cd backend
python main.py
# 前端(端口 5173
npm run dev
```
访问 http://localhost:5173
## API 接口
### POST /v1/completions
流式获取补全建议
**请求:**
```json
{
"prefix": "# Title\n\nContent ",
"suffix": "",
"languageId": "markdown"
}
```
**响应SSE**
```
data: {"content": "here"}
data: {"content": "here is"}
data: {"done": true}
```
## 核心实现
### 后端设计
#### main.py - FastAPI 服务器
- 定义 `/v1/completions` 端点
- 使用 `StreamingResponse` 返回 SSE 流式响应
- CORS 配置允许跨域请求
#### llm.py - LLM 调用封装
- 使用 `ollama.AsyncClient` 异步调用
- 支持 `think='high'` 思考模式
- 返回 `content` 和 `thinking` 字段
#### prompt.py - Prompt 工程
精心设计的 Prompt 模板,包含 7 条核心规则:
| 规则 | 说明 |
|------|------|
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
### 前端设计
#### ProseMirror Mark 系统
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
```typescript
// 定义 ghost mark
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
toDOM: () => ['span', {
'data-copilot-ghost': '',
class: 'copilot-ghost-text'
}, 0]
}))
// CSS 样式
.copilot-ghost-text {
color: #999;
opacity: 0.6;
}
```
#### copilotPlugin 核心逻辑
```mermaid
flowchart LR
A[用户输入] --> B{文档变化?}
B -->|是| C[清除旧建议]
C --> D[防抖 1000ms]
D --> E[发送 API 请求]
E --> F[收到建议]
F --> G[插入 Ghost Text]
G --> H{用户操作}
H -->|Tab| I[接受建议<br/>移除 mark]
H -->|Esc| J[拒绝建议<br/>删除文本]
H -->|点击 Ghost| I
H -->|继续输入| J
```
#### 关键函数
| 函数 | 作用 |
|------|------|
| `scheduleFetch` | 防抖调度 API 请求 |
| `insertGhostText` | 插入带 mark 的建议文本 |
| `acceptSuggestion` | Tab 接受建议 |
| `rejectSuggestion` | Esc 拒绝建议 |
| `clearGhostText` | 清除当前建议 |
### 数据流
```mermaid
sequenceDiagram
participant U as 用户
participant E as Editor (ProseMirror)
participant P as copilotPlugin
participant A as api.js
participant B as Backend
participant L as LLM
U->>E: 输入文本
E->>P: view.update()
P->>P: 清除旧建议
P->>P: 防抖 1000ms
P->>A: fetchSuggestion(prefix, suffix)
A->>B: POST /v1/completions
B->>B: build_prompt()
B->>L: ollama.chat()
L-->>B: {content, thinking}
B-->>A: SSE stream
A-->>P: suggestion text
P->>E: insertGhostText()
E-->>U: 显示灰色建议
alt Tab 键
U->>P: Tab
P->>E: acceptSuggestion()
E-->>U: 建议变为正常文本
else Esc 键
U->>P: Esc
P->>E: rejectSuggestion()
E-->>U: 建议消失
else 继续输入
U->>E: 输入其他字符
E->>P: handleKeyDown()
P->>E: clearGhostText()
end
```
## 设计亮点
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
4. **多种交互方式**Tab/Esc/点击/输入,用户体验友好
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
## 许可证
MIT License

BIN
README_clean.md Normal file

Binary file not shown.

BIN
README_correct.md Normal file

Binary file not shown.

BIN
README_fixed.md Normal file

Binary file not shown.

265
README_original.txt Normal file
View File

@@ -0,0 +1,265 @@
# LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能提供类似 GitHub Copilot 的 Ghost Text 体验。
## 功能特性
### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持完整 Markdown 语法和 LaTeX 公式
- 导入/导出 Markdown 文件
### AI 智能补全
- 实时生成文本补全建议(灰色显示)
- 流式响应,低延迟体验
- 多种交互方式:
- **Tab 键**:接受建议
- **Esc 键**:拒绝建议
- **点击灰色文本**:接受建议
- **继续输入**:自动拒绝建议
### AI 开关控制
- 右下角 AI 开关按钮
- 白色 = AI 启用,黑色 = AI 禁用
- 禁用时自动清除灰色文本并停止 API 调用
## 技术架构
```mermaid
flowchart TB
subgraph Frontend["前端 (Vue3 + Vite)"]
A[App.vue] --> B[MilkdownEditor.vue]
B --> C[Crepe Editor]
C --> D[ProseMirror]
D --> E[copilotPlugin.ts]
E --> F[copilotGhostMark]
E --> G[api.js]
end
subgraph Backend["后端 (FastAPI + Python)"]
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
H --> J[llm.py<br/>Ollama 调用]
J --> K[Ollama API]
end
G -->|POST /v1/completions<br/>SSE 流式响应| H
K -->|LLM 响应| J
```
## 项目结构
```
llm-in-text/
├── src/
│ ├── components/
│ │ └── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
│ │ ├── types.ts # 类型定义
│ │ └── index.ts # 插件导出
│ ├── utils/
│ │ ├── api.js # API 调用封装
│ │ ├── config.js # 配置文件
│ │ └── ocrCache.js # OCR 缓存管理
│ ├── App.vue
│ └── main.js
├── backend/
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
│ └── requirements.txt
└── README.md
```
## 快速开始
### 环境要求
- Node.js 18+
- Python 3.8+
- Ollama 服务(或其他兼容 OpenAI API 的服务)
### 安装
```bash
# 前端
npm install
# 后端
cd backend
pip install -r requirements.txt
```
### 配置
在 `backend/.env` 中配置:
```env
OLLAMA_MODEL=gpt-oss:20b
OLLAMA_HOST=http://localhost:11434
```
### 启动
```bash
# 后端(端口 8000
cd backend
python main.py
# 前端(端口 5173
npm run dev
```
访问 http://localhost:5173
## API 接口
### POST /v1/completions
流式获取补全建议
**请求:**
```json
{
"prefix": "# Title\n\nContent ",
"suffix": "",
"languageId": "markdown"
}
```
**响应SSE**
```
data: {"content": "here"}
data: {"content": "here is"}
data: {"done": true}
```
## 核心实现
### 后端设计
#### main.py - FastAPI 服务器
- 定义 `/v1/completions` 端点
- 使用 `StreamingResponse` 返回 SSE 流式响应
- CORS 配置允许跨域请求
#### llm.py - LLM 调用封装
- 使用 `ollama.AsyncClient` 异步调用
- 支持 `think='high'` 思考模式
- 返回 `content` 和 `thinking` 字段
#### prompt.py - Prompt 工程
精心设计的 Prompt 模板,包含 7 条核心规则:
| 规则 | 说明 |
|------|------|
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
### 前端设计
#### ProseMirror Mark 系统
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
```typescript
// 定义 ghost mark
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
toDOM: () => ['span', {
'data-copilot-ghost': '',
class: 'copilot-ghost-text'
}, 0]
}))
// CSS 样式
.copilot-ghost-text {
color: #999;
opacity: 0.6;
}
```
#### copilotPlugin 核心逻辑
```mermaid
flowchart LR
A[用户输入] --> B{文档变化?}
B -->|是| C[清除旧建议]
C --> D[防抖 1000ms]
D --> E[发送 API 请求]
E --> F[收到建议]
F --> G[插入 Ghost Text]
G --> H{用户操作}
H -->|Tab| I[接受建议<br/>移除 mark]
H -->|Esc| J[拒绝建议<br/>删除文本]
H -->|点击 Ghost| I
H -->|继续输入| J
```
#### 关键函数
| 函数 | 作用 |
|------|------|
| `scheduleFetch` | 防抖调度 API 请求 |
| `insertGhostText` | 插入带 mark 的建议文本 |
| `acceptSuggestion` | Tab 接受建议 |
| `rejectSuggestion` | Esc 拒绝建议 |
| `clearGhostText` | 清除当前建议 |
### 数据流
```mermaid
sequenceDiagram
participant U as 用户
participant E as Editor (ProseMirror)
participant P as copilotPlugin
participant A as api.js
participant B as Backend
participant L as LLM
U->>E: 输入文本
E->>P: view.update()
P->>P: 清除旧建议
P->>P: 防抖 1000ms
P->>A: fetchSuggestion(prefix, suffix)
A->>B: POST /v1/completions
B->>B: build_prompt()
B->>L: ollama.chat()
L-->>B: {content, thinking}
B-->>A: SSE stream
A-->>P: suggestion text
P->>E: insertGhostText()
E-->>U: 显示灰色建议
alt Tab 键
U->>P: Tab
P->>E: acceptSuggestion()
E-->>U: 建议变为正常文本
else Esc 键
U->>P: Esc
P->>E: rejectSuggestion()
E-->>U: 建议消失
else 继续输入
U->>E: 输入其他字符
E->>P: handleKeyDown()
P->>E: clearGhostText()
end
```
## 设计亮点
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
4. **多种交互方式**Tab/Esc/点击/输入,用户体验友好
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
## 许可证
MIT License

View File

@@ -9,9 +9,14 @@ from dotenv import load_dotenv
load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
# Timeouts in seconds
COMPLETION_TIMEOUT = 30
OCR_TIMEOUT = 60
CONVERT_TIMEOUT = 30
client = ollama.AsyncClient(host=OLLAMA_HOST)
logger = logging.getLogger("llm")
@@ -97,7 +102,7 @@ async def call_ollama(
if thinking:
kwargs["think"] = thinking
response = await client.chat(**kwargs)
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
@@ -156,15 +161,18 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
)
try:
response = await client.chat(
model=VLM_MODEL,
messages=[{
'role': 'user',
'content': VLM_OCR_CONTEXT_PROMPT,
'images': [image_bytes]
}],
stream=False,
options={'temperature': 0.3}
response = await asyncio.wait_for(
client.chat(
model=VLM_MODEL,
messages=[{
'role': 'user',
'content': VLM_OCR_CONTEXT_PROMPT,
'images': [image_bytes]
}],
stream=False,
options={'temperature': 0.3}
),
timeout=OCR_TIMEOUT
)
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000

View File

@@ -1,4 +1,4 @@
import asyncio
import asyncio
import base64
import json
import logging
@@ -238,7 +238,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
)
image_bytes = base64.b64decode(request.image)
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(
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
request_id,
@@ -253,7 +253,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
@app.post("/v1/convert")
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
"""将文件转换为Markdown格式"""
"""鐏忓棙鏋冩禒鎯版祮閹诡澀璐烳arkdown閺嶇厧绱?""
request_id = str(uuid.uuid4())[:8]
try:
@@ -264,20 +264,20 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
len(request.file or ""),
)
# 解码Base64文件内容
# 鐟欙絿鐖淏ase64閺傚洣娆㈤崘鍛啇
file_bytes = base64.b64decode(request.file)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# 获取文件扩展名
# 閼惧嘲褰囬弬鍥︽閹碘晛鐫嶉崥?
ext = os.path.splitext(request.filename)[1].lower()
# 创建临时文件
# 閸掓稑缂撴稉瀛樻閺傚洣娆?
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
# 使用MarkItDown转换为Markdown
# 娴h法鏁arkItDown鏉烆剚宕叉稉绡梐rkdown
md = markitdown.MarkItDown()
result = md.convert(tmp_path)
markdown_text = result.text_content
@@ -294,7 +294,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
"filename": request.filename
}
finally:
# 清理临时文件
# 濞撳懐鎮婃稉瀛樻閺傚洣娆?
if os.path.exists(tmp_path):
os.unlink(tmp_path)
@@ -307,3 +307,9 @@ if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
# TTS and STT routes
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)

View File

@@ -1,4 +1,4 @@
fastapi
fastapi
uvicorn
ollama
pydantic
@@ -10,3 +10,10 @@ python-docx
python-pptx
openpyxl
pypdf
# TTS and ASR dependencies
torch
transformers
soundfile
numpy
accelerate

141
backend/tts_stt.py Normal file
View File

@@ -0,0 +1,141 @@
# TTS and Speech Recognition API for macOS Silicon
import os
import asyncio
import logging
import base64
from typing import Optional
from fastapi import APIRouter, UploadFile, File, HTTPException, Security
from pydantic import BaseModel
from fastapi.security import APIKeyHeader
router = APIRouter()
api_key_header = APIKeyHeader(name="X-API-Key")
logger = logging.getLogger("tts_stt")
def _speak_text_macos(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
output_path = tmp.name
try:
cmd = ["say", "-v", voice, "-r", str(rate * 10), "--output-format", "WAVE", "-o", output_path, text]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise Exception(f"TTS failed: {result.stderr}")
with open(output_path, "rb") as f:
audio_data = f.read()
return audio_data
finally:
if os.path.exists(output_path):
os.unlink(output_path)
async def _speak_text_macos_async(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _speak_text_macos, text, voice, rate)
def _recognize_speech_macos(audio_data: bytes, language: str = "zh-CN") -> str:
import tempfile
try:
import whisper
model = whisper.load_model("tiny")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp2:
tmp2.write(audio_data)
audio_for_whisper = tmp2.name
try:
result = model.transcribe(audio_for_whisper, language=language[:2])
return result["text"]
finally:
if os.path.exists(audio_for_whisper):
os.unlink(audio_for_whisper)
except ImportError:
raise Exception("Whisper is required for speech recognition on macOS")
async def _recognize_speech_macos_async(audio_data: bytes, language: str = "zh-CN") -> str:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _recognize_speech_macos, audio_data, language)
class TTSRequest(BaseModel):
text: str
voice: str = "meijia"
rate: float = 0.5
format: str = "wav"
class TTSResponse(BaseModel):
audio_base64: str
format: str
duration_ms: int
class STTRequest(BaseModel):
audio_base64: str
language: str = "zh-CN"
class STTResponse(BaseModel):
text: str
language: str
@router.post("/tts", response_model=TTSResponse)
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
request_id = str(hash(req.text))[:8]
try:
logger.info("[TTS][%s] text_chars=%d voice=%s", request_id, len(req.text), req.voice)
audio_data = await _speak_text_macos_async(req.text, req.voice, req.rate)
if req.format.lower() == "mp3":
import tempfile
import subprocess
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_in:
tmp_in.write(audio_data)
input_path = tmp_in.name
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_out:
output_path = tmp_out.name
try:
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", output_path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise Exception(f"MP3 conversion failed: {result.stderr}")
with open(output_path, "rb") as f:
audio_data = f.read()
finally:
for p in [input_path, output_path]:
if os.path.exists(p):
os.unlink(p)
duration_ms = len(audio_data) * 1000 // 16000
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
return TTSResponse(audio_base64=base64.b64encode(audio_data).decode(), format=req.format, duration_ms=duration_ms)
except Exception as e:
logger.exception("[TTS] failed: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stt", response_model=STTResponse)
async def speech_to_text(req: STTRequest, api_key: str = Security(get_api_key)):
request_id = str(hash(req.audio_base64))[:8]
try:
logger.info("[STT][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
audio_data = base64.b64decode(req.audio_base64)
text = await _recognize_speech_macos_async(audio_data, req.language)
logger.info("[STT][%s] success text_chars=%d", request_id, len(text))
return STTResponse(text=text, language=req.language)
except Exception as e:
logger.exception("[STT] failed: %s", e)
raise HTTPException(status_code=500, detail=str(e))
def get_api_key(api_key: str):
from backend.main import API_KEY
if api_key != API_KEY:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Could not validate credentials")
return api_key
def register_tts_stt_routes(app):
app.include_router(router, prefix="/v1/tts-stt")

BIN
original_readme.md Normal file

Binary file not shown.

1907
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "echo 'No tests configured yet'",
"check": "npm run build"
},
"dependencies": {
"@milkdown/core": "^7.18.0",

View File

@@ -0,0 +1,250 @@
<template>
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
<div class="doc-header">
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<div class="doc-name">{{ docName }}</div>
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<div class="doc-editor" v-show="!isCollapsed">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
const props = defineProps({
docType: { type: String, default: 'text' },
docName: { type: String, default: 'document.txt' },
uploadTime: { type: String, default: '' },
initialContent: { type: String, default: '' }
})
const emit = defineEmits(['update:content', 'delete'])
const editorRoot = ref(null)
const isCollapsed = ref(false)
let crepe = null
let internalChangeTimer = null
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
if (!crepe) return
crepe.getMarkdown().then(markdown => {
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
})
}
const syncContent = () => {
if (!crepe) return
if (internalChangeTimer) clearTimeout(internalChangeTimer)
internalChangeTimer = setTimeout(async () => {
const markdown = await crepe.getMarkdown()
emit('update:content', markdown)
}, 120)
}
onMounted(async () => {
if (!editorRoot.value) return
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent || '',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.ListCheck]: true,
},
config: { showLineNumber: false }
})
crepe.editor.config(ctx => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 1000
})
})
crepe.editor.use(copilotPlugin)
await crepe.create()
crepe.on(listener => {
listener.updated(() => {
syncContent()
})
})
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, true)
})
})
watch(() => props.initialContent, (newVal) => {
if (crepe && newVal !== undefined) {
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const currentPos = view.state.selection.from
view.dispatch(view.state.tr.insertText(newVal))
})
}
})
onUnmounted(() => {
if (internalChangeTimer) clearTimeout(internalChangeTimer)
if (crepe) {
crepe.destroy()
crepe = null
}
})
defineExpose({
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
getEditor: () => crepe
})
</script>
<style scoped>
.doc-block-crepe {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block-crepe.collapsed .doc-editor {
display: none;
}
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
}
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
.doc-editor {
padding: 8px;
background: var(--crepe-color-surface-low);
min-height: 120px;
max-height: 400px;
overflow-y: auto;
}
.inner-crepe {
width: 100%;
height: 100%;
}
.inner-crepe :deep(.milkdown) {
background: transparent !important;
}
.inner-crepe :deep(.ProseMirror) {
min-height: 80px;
padding: 8px !important;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="doc-block" :class="{ collapsed: isCollapsed }">
<!-- 深色条文件头 -->
<div class="doc-header">
<!-- 最左边文件类型icon -->
<div class="doc-icon">
<!-- PDF icon -->
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<!-- Word icon -->
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<!-- PPT icon -->
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<!-- TXT icon -->
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<!-- 中间文件名 -->
<div class="doc-name">{{ docName }}</div>
<!-- 最右边下载按钮 + 折叠按钮 -->
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- 浅色块文档内容非折叠状态显示 -->
<div class="doc-content" v-show="!isCollapsed">
<pre>{{ content }}</pre>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
docType: {
type: String,
default: 'text'
},
docName: {
type: String,
default: 'document.txt'
},
uploadTime: {
type: String,
default: ''
},
content: {
type: String,
default: ''
}
})
const isCollapsed = ref(false)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
const blob = new Blob([props.content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.doc-block {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block.collapsed .doc-content {
display: none;
}
/* 深色条 */
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
/* 文件类型icon */
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
}
/* 文件名 */
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
/* 浅色块:文档内容 */
.doc-content {
padding: 12px;
background: var(--crepe-color-surface-low);
max-height: 400px;
overflow-y: auto;
}
.doc-content pre {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--crepe-color-on-surface);
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

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

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
@@ -1198,3 +1198,4 @@ onUnmounted(() => {
}
</style>

View File

@@ -2,10 +2,13 @@
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import packageJson from '../../package.json'
const store = useSettingsStore()
const { setTheme } = useTheme()
const VERSION = packageJson.version || '0.0.0'
const isOpen = ref(false)
let systemThemeMediaQuery = null
@@ -272,7 +275,7 @@ const t = (key) => store.t[key]
<div class="about-card">
<h4>llm-in-text</h4>
<p>A smart Markdown editor with local LLM intelligence.</p>
<p class="version">v0.1.0-beta</p>
<p class="version">v{{ VERSION }}</p>
</div>
</section>
</div>

View File

@@ -10,7 +10,7 @@ const app = createApp(App)
app.use(createPinia())
app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Service worker registration failed, silently ignore

View File

@@ -9,6 +9,7 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface CopilotState {
@@ -330,6 +331,31 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
return `\n\n${lines.join('\n')}`
}
// 从markdown中提取文档块内容用于AI补全上下文
function extractDocBlocksFromMarkdown(markdown: string): string {
const lines: string[] = []
// 使用正则表达式匹配文档块
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
let match
while ((match = docBlockRegex.exec(markdown)) !== null) {
const docType = match[1]
const docName = match[2]
const content = match[4].trim()
if (content) {
// 将文档内容格式化为上下文,限制长度
const truncatedContent = content.length > 500 ? content.substring(0, 500) + '...' : content
lines.push(`<doc_type="${docType}" doc_name="${docName}">\n${truncatedContent}\n</doc_end>`)
}
}
if (lines.length === 0) return ''
return `\n\n-- 已上传文档内容 --\n${lines.join('\n\n')}`
}
function doFetchSuggestion(
view: EditorView,
runtime: CopilotRuntime,
@@ -379,7 +405,6 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
const doc = view.state.doc
const schema = view.state.schema
const baseSize = doc.content.size
const serializer = runtime.ctx.get(serializerCtx)
let prefixMarkdown = ''
@@ -400,12 +425,21 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
}
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
// 构建上下文OCR内容 + 上传文档内容
const ocrContext = buildOcrContextForRequest(doc, pos)
// 从markdown中提取文档块内容用于AI补全上下文
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
// 组合所有上下文到prefix前面
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
const totalWithContext = totalTextLen + contextLen
const overLimit = totalWithOcr > SIZE_LIMIT
// 使用32KB限制文档上下文
const overLimit = totalWithContext > DOC_SIZE_LIMIT
if (overLimit) {
setCopilotEnabled(view, false)
@@ -422,9 +456,10 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
runtime.requestSeq = requestSeq
const requestDocVersion = runtime.docVersion
// 使用包含文档上下文的prefix
runtime.debounceTimer = setTimeout(() => {
runtime.debounceTimer = null
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion)
doFetchSuggestion(view, runtime, pos, fullPrefixWithContext, suffixMarkdown, requestSeq, requestDocVersion)
}, debounceMs)
}

View File

@@ -13,7 +13,7 @@ export const useSettingsStore = defineStore('settings', () => {
const debounceMs = ref(1000) // 1000 - 5000
// 3. Privacy
const privacyMode = ref(false)
const privacyMode = ref(true)
// 4. Preferences
const language = ref('auto')

View File

@@ -1,10 +1,6 @@
import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
const API_KEY = 'your-secret-key-here'
let cachedIP = null
function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
@@ -34,7 +30,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
@@ -46,20 +41,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
}
}
async function getClientIP() {
if (cachedIP) return cachedIP
try {
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000)
const res = await fetch('https://api.ipify.org?format=json', { signal: controller.signal })
const data = await res.json()
cachedIP = data.ip
return cachedIP
} catch {
return null
}
}
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
@@ -89,18 +70,11 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
try {
const settings = useSettingsStore()
const clientIP = await getClientIP()
const headers = {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'X-Request-Id': requestId,
}
// Only send IP if privacy mode is OFF
if (clientIP && !settings.privacyMode) {
headers['X-Client-IP'] = clientIP
}
const body = {
prefix,
suffix,

View File

@@ -1,3 +1,5 @@
export const DEBUG = import.meta.env.DEV
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`

View File

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

View File

@@ -35,6 +35,11 @@ export const translations = {
exportPdf: 'Export PDF',
uploadImg: 'Upload Image',
uploadFile: 'Upload File',
uploadDoc: 'Upload Document',
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
@@ -84,6 +89,11 @@ export const translations = {
exportPdf: '导出 PDF',
uploadImg: '上传图片',
uploadFile: '上传文件',
uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md和图片文件。',
uploadFileError: '文件上传失败',

0
test.txt Normal file
View File

BIN
update_readme.py Normal file

Binary file not shown.