neoctl-web 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -216
- package/chunk-uploads.mjs +125 -0
- package/core-runtime.mjs +1 -0
- package/dist/assets/App-DkTv-X6M.js +105 -0
- package/dist/assets/App-DmtsTXw2.css +1 -0
- package/dist/assets/index-BwXWTT2b.js +18 -0
- package/dist/assets/index-Dgfhk2Ym.css +1 -0
- package/dist/index.html +2 -2
- package/execution-backend.mjs +74 -0
- package/isolation-auth.mjs +225 -0
- package/isolation.example.json +8 -0
- package/isolation.mjs +385 -0
- package/package.json +20 -12
- package/plugins/downloads/README.md +40 -0
- package/plugins/downloads/downloads.mjs +81 -120
- package/plugins/downloads/index.mjs +10 -8
- package/plugins/downloads/neo-plugin.json +2 -2
- package/plugins/video-share/README.md +55 -0
- package/plugins/video-share/http.mjs +86 -0
- package/plugins/video-share/index.mjs +65 -0
- package/plugins/video-share/neo-plugin.json +9 -0
- package/plugins/video-share/presentation.mjs +23 -0
- package/plugins/video-share/store.mjs +76 -0
- package/plugins/xhs-artifact/editor-page.mjs +4 -4
- package/runtime-workspaces.mjs +9 -6
- package/scripts/isolation-user.mjs +68 -0
- package/server.mjs +26 -48
- package/control-protocol.mjs +0 -44
- package/control-sync.mjs +0 -420
- package/dist/assets/index-9hY14np_.css +0 -1
- package/dist/assets/index-BnJuxSfa.js +0 -121
- package/plugins/xhs-artifact/version.test.mjs +0 -151
package/README.md
CHANGED
|
@@ -1,244 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Neo Web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
基于 Vue 3 和 Vite 的浏览器工作台,使用 Neo Engine 处理对话和工具调用。提供会话管理、图片上传、运行状态查看和插件功能。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装使用
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
需要 Node.js 20+。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
8
10
|
npm install -g neoctl-web
|
|
9
11
|
neow
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
`neow`
|
|
14
|
+
`neow` 自动打开浏览器,默认地址为 `http://127.0.0.1:5173`,端口占用时自动顺延。在页面中填写模型 API 地址、密钥和模型名称即可开始对话。
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
## 源码开发
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
在仓库根目录执行:
|
|
17
19
|
|
|
18
|
-
```
|
|
19
|
-
npm
|
|
20
|
+
```sh
|
|
21
|
+
npm ci --prefix engine
|
|
22
|
+
npm ci --prefix web
|
|
23
|
+
npm --prefix web run dev
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- Neo 运行时:`http://127.0.0.1:3101`(仅本机)
|
|
25
|
-
- Vue 单页应用:`http://0.0.0.0:5173`(本机及局域网)
|
|
26
|
-
|
|
27
|
-
局域网设备可通过 `http://<本机局域网 IP>:5173` 访问。Windows 防火墙需要允许本地子网访问 TCP 5173;不要将该端口直接映射到公网,因为应用内 Agent 具备文件读写和命令执行能力。若只允许本机访问,可设置 `VITE_HOST=127.0.0.1`。
|
|
28
|
-
|
|
29
|
-
每个新建对话会在用户数据目录的 `workspaces/YYMMDDHHMMSS` 下创建独立工作目录,不会向启动 `neow` 的当前目录写入数据。默认用户数据目录遵循各平台约定:
|
|
30
|
-
|
|
31
|
-
- Windows:`%LOCALAPPDATA%\neoctl-web`
|
|
32
|
-
- macOS:`~/Library/Application Support/neoctl-web`
|
|
33
|
-
- Linux:`${XDG_DATA_HOME:-~/.local/share}/neoctl-web`
|
|
34
|
-
|
|
35
|
-
可通过 `NEO_WEB_DATA_DIR` 覆盖整个数据目录,或通过 `NEO_WORKSPACE_ROOT` 单独覆盖 workspace 根目录;会话恢复时会自动回到该会话原有的工作目录。
|
|
36
|
-
|
|
37
|
-
同一个 `sessionId` 只保留一个运行时。多个浏览器标签页或用户打开同一会话时会共享实时输出和输入队列,不会各自启动一份并发 Agent。会话运行时在无人连接且没有前台或后台任务后自动回收。
|
|
38
|
-
|
|
39
|
-
可通过以下环境变量限制常驻内存和旁观连接:
|
|
40
|
-
|
|
41
|
-
```env
|
|
42
|
-
# 空闲运行时回收时间,默认 15 分钟,最小 60 秒
|
|
43
|
-
NEO_RUNTIME_IDLE_MS=900000
|
|
44
|
-
|
|
45
|
-
# 最多保留的空闲 session 运行时,默认 64;活跃运行时不会被强制驱逐
|
|
46
|
-
NEO_RUNTIME_MAX_SESSIONS=64
|
|
47
|
-
|
|
48
|
-
# 每个 session 最多同时连接的 SSE 客户端,默认 32
|
|
49
|
-
NEO_SESSION_MAX_SUBSCRIBERS=32
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
右侧栏会显示 Neo 服务进程的内存使用趋势。默认每分钟采样一次,接口单次最多返回最近 60 个点;落盘数据默认最多保留 1440 个点且不超过 256 KiB,并原子写入用户数据目录的 `memory-monitor.json`。可通过 `NEO_MEMORY_MAX_PERSISTED_SAMPLES` 和 `NEO_MEMORY_MAX_PERSISTED_BYTES` 进一步收紧限制;该功能仅观测和展示,不会自动回收会话或重启进程。
|
|
53
|
-
|
|
54
|
-
```env
|
|
55
|
-
# 可选:内存采样间隔与落盘保留窗口(毫秒)
|
|
56
|
-
NEO_MEMORY_SAMPLE_MS=60000
|
|
57
|
-
NEO_MEMORY_RETENTION_MS=86400000
|
|
58
|
-
```
|
|
26
|
+
打开 `http://localhost:5173`。`dev` 会构建并使用本地 Engine;`dev:package` 改用 npm 安装的核心。
|
|
59
27
|
|
|
60
|
-
|
|
28
|
+
## 生产启动
|
|
61
29
|
|
|
62
|
-
|
|
30
|
+
在 `web/` 目录执行:
|
|
63
31
|
|
|
64
|
-
|
|
65
|
-
- `/api/state`:运行时状态
|
|
66
|
-
- `/api/runtime-context`:当前 Agent 的完整系统提示词、上下文和工具协议快照
|
|
67
|
-
- `/api/submit`:提交用户消息和附件
|
|
68
|
-
- `/api/interrupt`:中断当前任务
|
|
69
|
-
- `/api/sessions/*`:会话列表、恢复、新建、删除
|
|
70
|
-
- `/api/login`:模型供应商配置
|
|
71
|
-
- `/vendor/*`:neo web 运行时静态资源
|
|
72
|
-
|
|
73
|
-
`expose_downloads` 可暴露任意现有绝对文件路径,不受当前工作目录限制;下载链接仍为临时链接并按注册表有效期失效。
|
|
74
|
-
|
|
75
|
-
如果只想启动纯前端 Vite:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
npm run dev:ui
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
## 构建
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
npm run build
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
## 生产部署
|
|
88
|
-
|
|
89
|
-
请使用 Node.js 20 或更高版本。
|
|
90
|
-
|
|
91
|
-
```bash
|
|
32
|
+
```sh
|
|
92
33
|
npm ci
|
|
93
34
|
npm start
|
|
94
35
|
```
|
|
95
36
|
|
|
96
|
-
`npm start`
|
|
97
|
-
|
|
98
|
-
WSL/PM2 服务器可使用 `bin/` 下的运维脚本:
|
|
99
|
-
|
|
100
|
-
```bash
|
|
101
|
-
./bin/deploy.sh # 拉取、安装、构建并重启
|
|
102
|
-
./bin/start.sh # 启动生产服务
|
|
103
|
-
./bin/stop.sh # 停止服务
|
|
104
|
-
./bin/restart.sh # 重启服务
|
|
105
|
-
./bin/status.sh # 查看进程与 HTTP 健康状态
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
WSL 开机入口为 `bin/wsl-boot.sh`,它会恢复生产进程,并按当前 WSL IP 刷新 Windows 的 `22` 和 `5173` 端口转发。
|
|
109
|
-
|
|
110
|
-
## 单页应用能力
|
|
111
|
-
|
|
112
|
-
已实现:
|
|
113
|
-
|
|
114
|
-
- 用户与模型聊天
|
|
115
|
-
- 复用 `neoctl` Web API/SSE 协议
|
|
116
|
-
- 流式助手输出
|
|
117
|
-
- 推理过程、工具、系统、用户消息展示
|
|
118
|
-
- 工具调用输出折叠/展开
|
|
119
|
-
- 状态栏:模型、上下文占用、输入/输出 token、运行阶段
|
|
120
|
-
- 后台任务摘要
|
|
121
|
-
- 会话列表、恢复、新建、删除
|
|
122
|
-
- 模型登录/配置表单
|
|
123
|
-
- 图片粘贴附件,沿用 neo web 的 `[img#N]` 协议
|
|
124
|
-
- Render.com 风格的侧边栏、顶部栏、卡片和工作台布局
|
|
125
|
-
|
|
126
|
-
### 运行上下文订阅协议
|
|
127
|
-
|
|
128
|
-
浏览器连接 `/events` 后,除会话用的 `sync` / `delta` 事件外,还会收到 `runtime.context` 事件。事件数据为 JSON,当前 `protocolVersion` 为 `1`,主要字段如下:
|
|
129
|
-
|
|
130
|
-
```json
|
|
131
|
-
{
|
|
132
|
-
"protocolVersion": 1,
|
|
133
|
-
"revision": 1,
|
|
134
|
-
"sessionId": "...",
|
|
135
|
-
"model": "gpt-5.6-sol",
|
|
136
|
-
"prompt": {
|
|
137
|
-
"systemPrompt": "合成后的完整系统提示词",
|
|
138
|
-
"sections": [
|
|
139
|
-
{ "name": "Agent Scaffold", "content": "...", "cacheStable": true, "chars": 123 }
|
|
140
|
-
],
|
|
141
|
-
"appPrompt": {},
|
|
142
|
-
"userContext": {},
|
|
143
|
-
"systemContext": {}
|
|
144
|
-
},
|
|
145
|
-
"tools": [
|
|
146
|
-
{ "name": "read", "description": "...", "inputSchema": {}, "strict": false }
|
|
147
|
-
],
|
|
148
|
-
"capabilities": {
|
|
149
|
-
"commands": [], "agents": [], "skills": [], "plugins": []
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
首次订阅、切换/新建会话、修改模型、保存模型配置或切换应用提示词时会发布新 revision。客户端读取速度较慢时,服务端会在 SSE drain 后补发最新上下文,不会用普通 `sync` 事件替代。`GET /api/runtime-context` 提供相同结构的即时快照,可用于首次加载或断线恢复。
|
|
155
|
-
|
|
156
|
-
### Web 插件
|
|
157
|
-
|
|
158
|
-
下载和小红书编辑器以目录资源插件提供,不再由 core 或 Web 后台写死。插件协议由 core 的 `neo-plugin/v1` 定义,core 负责读取清单、动态导入入口、校验工具/提示词/HTTP 路由能力;Web 后台只指定插件目录并托管已加载资源。插件按 id 固定排序。
|
|
37
|
+
`npm start` 会先构建前端,再启动服务,默认监听 `0.0.0.0:5173`,使用 npm 核心。已有本地 Engine 构建时,可运行 `npm start -- --core local`。
|
|
159
38
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
```text
|
|
163
|
-
plugins/example/
|
|
164
|
-
neo-plugin.json
|
|
165
|
-
index.mjs
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
```json
|
|
169
|
-
{
|
|
170
|
-
"protocol": "neo-plugin/v1",
|
|
171
|
-
"id": "example",
|
|
172
|
-
"name": "Example",
|
|
173
|
-
"version": "1.0.0",
|
|
174
|
-
"entry": "index.mjs",
|
|
175
|
-
"defaultEnabled": true
|
|
176
|
-
}
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
入口需导出 `createPlugin(context)` 或默认工厂函数,并返回 `{ tools, promptSections, route }` 中的一项或多项。`NEO_WEB_PLUGIN_DIR` 可指定其他插件根目录,`NEO_WEB_PLUGIN_DATA_DIR` 可指定传给插件的通用数据目录;插件专属配置由插件自行从 `context.env` 读取。
|
|
180
|
-
|
|
181
|
-
- 全局开关位于“模型配置”,保存到用户数据目录的 `plugins.json`,重启后生效。
|
|
182
|
-
- 会话开关位于“运行上下文 → 插件”,支持跟随全局、启用和关闭,从下一轮请求生效并随会话持久化。
|
|
183
|
-
- `NEO_WEB_PLUGINS` 可作为部署级强制白名单;设置后全局界面只读。
|
|
184
|
-
|
|
185
|
-
`NEO_WEB_PLUGINS` 支持以下值:
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
# 默认启用所有标记为默认启用的插件
|
|
189
|
-
npm run dev
|
|
190
|
-
|
|
191
|
-
# 关闭全部 Web 插件
|
|
192
|
-
NEO_WEB_PLUGINS=none npm run dev
|
|
193
|
-
|
|
194
|
-
# 仅启用指定插件
|
|
195
|
-
NEO_WEB_PLUGINS=downloads,xhs-artifact npm run dev
|
|
196
|
-
```
|
|
39
|
+
常用环境变量:
|
|
197
40
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
暂未实现:
|
|
205
|
-
|
|
206
|
-
- 绘图工具/画布能力。等待 `neoctl` 后续提供绘图运行时后再接。
|
|
207
|
-
|
|
208
|
-
## neoctl 集成
|
|
209
|
-
|
|
210
|
-
本项目已安装 npm 依赖:
|
|
211
|
-
|
|
212
|
-
```bash
|
|
213
|
-
neoctl@^0.2.3
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
可用脚本:
|
|
217
|
-
|
|
218
|
-
```bash
|
|
219
|
-
npm run neo:help # 查看 neoctl 命令帮助
|
|
220
|
-
npm run neo # 启动 neo 命令行 REPL
|
|
221
|
-
npm run neo:web # 启动 neoctl 原生 Web UI,默认 127.0.0.1:3000
|
|
222
|
-
npm run neo:login # 交互式配置模型供应商
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
也可以直接使用:
|
|
226
|
-
|
|
227
|
-
```bash
|
|
228
|
-
npx neo -help
|
|
229
|
-
npx neo -web --port 3001
|
|
230
|
-
```
|
|
41
|
+
| 变量 | 用途 |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `APP_HOST` / `APP_PORT` | 生产服务监听地址和端口 |
|
|
44
|
+
| `VITE_HOST` / `VITE_PORT` | 开发服务监听地址和端口 |
|
|
45
|
+
| `NEO_WEB_DATA_DIR` | Web 数据目录 |
|
|
46
|
+
| `NEO_WORKSPACE_ROOT` | 会话工作目录的根路径 |
|
|
231
47
|
|
|
232
|
-
|
|
48
|
+
默认 Web 数据目录为 Windows 的 `%LOCALAPPDATA%\neoctl-web`、macOS 的 `~/Library/Application Support/neoctl-web`、Linux 的 `${XDG_DATA_HOME:-~/.local/share}/neoctl-web`。
|
|
233
49
|
|
|
234
|
-
|
|
50
|
+
## 开发命令
|
|
235
51
|
|
|
236
|
-
|
|
52
|
+
以下命令在 `web/` 目录执行:
|
|
237
53
|
|
|
238
|
-
```
|
|
239
|
-
|
|
54
|
+
```sh
|
|
55
|
+
npm run build # 构建前端
|
|
56
|
+
npm test # 非浏览器测试,需先构建本地 Engine
|
|
57
|
+
npm run test:server # 服务启动与模型配置回归
|
|
240
58
|
```
|
|
241
59
|
|
|
242
|
-
|
|
60
|
+
页面源码在 `src/`,服务入口为 `server.mjs`,插件在 `plugins/`,测试在 `tests/`。
|
|
243
61
|
|
|
244
|
-
|
|
62
|
+
测试说明见 [tests/README.md](tests/README.md);多用户配置见 [isolation.example.json](isolation.example.json) 和 [用户管理脚本](scripts/isolation-user.mjs)。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export const UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024
|
|
6
|
+
const prefix = '/api/uploads/chunks'
|
|
7
|
+
|
|
8
|
+
// No total file-size limit. Only individual requests are bounded, keeping memory
|
|
9
|
+
// usage constant. Each chunk is appended to one file; completion is an atomic rename.
|
|
10
|
+
export function createChunkUploadHandler({ uploadsDir, baseDir, finalize }) {
|
|
11
|
+
const sessions = new Map()
|
|
12
|
+
const partialDir = path.join(uploadsDir, '.partial')
|
|
13
|
+
const reply = (res, value, status = 200) => {
|
|
14
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
15
|
+
res.end(JSON.stringify(value))
|
|
16
|
+
}
|
|
17
|
+
const fail = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
18
|
+
async function metadata(req) {
|
|
19
|
+
const chunks = []
|
|
20
|
+
let bytes = 0
|
|
21
|
+
for await (const chunk of req) {
|
|
22
|
+
bytes += chunk.length
|
|
23
|
+
if (bytes > 16384) throw fail('上传元数据过大')
|
|
24
|
+
chunks.push(chunk)
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
27
|
+
}
|
|
28
|
+
async function discard(id, session) {
|
|
29
|
+
await fs.rm(session.partialPath, { force: true })
|
|
30
|
+
sessions.delete(id)
|
|
31
|
+
}
|
|
32
|
+
// Only abandoned incomplete uploads are cleaned up; completed files never expire.
|
|
33
|
+
const cleanup = setInterval(() => {
|
|
34
|
+
for (const [id, session] of sessions) {
|
|
35
|
+
if (!session.busy && Date.now() - session.updated > 24 * 60 * 60 * 1000) {
|
|
36
|
+
session.busy = true
|
|
37
|
+
void discard(id, session).catch(() => { session.busy = false })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, 60 * 60 * 1000)
|
|
41
|
+
cleanup.unref()
|
|
42
|
+
|
|
43
|
+
const handle = async (req, res, url) => {
|
|
44
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(prefix + '/')) return false
|
|
45
|
+
try {
|
|
46
|
+
if (url.pathname === prefix && req.method === 'POST') {
|
|
47
|
+
const body = await metadata(req)
|
|
48
|
+
const name = path.basename(String(body.name || '').replace(/\\/g, '/')).replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-').trim().slice(0, 180)
|
|
49
|
+
if (!name || name === '.' || name === '..') throw fail('文件名无效')
|
|
50
|
+
if (!Number.isSafeInteger(body.size) || body.size < 0) throw fail('文件大小无效')
|
|
51
|
+
const id = randomUUID()
|
|
52
|
+
await fs.mkdir(partialDir, { recursive: true })
|
|
53
|
+
const partialPath = path.join(partialDir, id + '.part')
|
|
54
|
+
const file = await fs.open(partialPath, 'wx')
|
|
55
|
+
await file.close()
|
|
56
|
+
sessions.set(id, { name, size: body.size, mimeType: String(body.mimeType || 'application/octet-stream'), partialPath, offset: 0, busy: false, updated: Date.now() })
|
|
57
|
+
reply(res, { ok: true, uploadId: id, offset: 0, chunkBytes: UPLOAD_CHUNK_BYTES })
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
const match = url.pathname.slice(prefix.length).match(/^\/([a-f0-9-]{36})(\/complete)?$/)
|
|
61
|
+
if (!match) throw fail('上传地址无效', 404)
|
|
62
|
+
const id = match[1]
|
|
63
|
+
const session = sessions.get(id)
|
|
64
|
+
if (!session) throw fail('未完成的上传不存在,请重新上传', 404)
|
|
65
|
+
session.updated = Date.now()
|
|
66
|
+
if (session.busy) throw fail('上一分片仍在处理中,请重试', 409)
|
|
67
|
+
if (req.method === 'GET' && !match[2]) {
|
|
68
|
+
reply(res, { ok: true, offset: session.offset })
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
session.busy = true
|
|
72
|
+
try {
|
|
73
|
+
if (req.method === 'DELETE' && !match[2]) {
|
|
74
|
+
await discard(id, session)
|
|
75
|
+
reply(res, { ok: true })
|
|
76
|
+
} else if (req.method === 'PATCH' && !match[2]) {
|
|
77
|
+
const offset = Number(req.headers['x-upload-offset'])
|
|
78
|
+
if (!Number.isSafeInteger(offset) || offset !== session.offset) throw fail('分片偏移不匹配', 409)
|
|
79
|
+
const file = await fs.open(session.partialPath, 'r+')
|
|
80
|
+
let received = 0
|
|
81
|
+
try {
|
|
82
|
+
for await (const chunk of req) {
|
|
83
|
+
if (received + chunk.length > UPLOAD_CHUNK_BYTES || offset + received + chunk.length > session.size) throw fail('分片大小无效', 413)
|
|
84
|
+
let written = 0
|
|
85
|
+
while (written < chunk.length) {
|
|
86
|
+
const result = await file.write(chunk, written, chunk.length - written, offset + received + written)
|
|
87
|
+
if (!result.bytesWritten) throw new Error('磁盘写入失败')
|
|
88
|
+
written += result.bytesWritten
|
|
89
|
+
}
|
|
90
|
+
received += chunk.length
|
|
91
|
+
}
|
|
92
|
+
if (!received) throw fail('分片为空')
|
|
93
|
+
session.offset += received
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await file.truncate(offset)
|
|
96
|
+
throw error
|
|
97
|
+
} finally {
|
|
98
|
+
await file.close()
|
|
99
|
+
}
|
|
100
|
+
reply(res, { ok: true, offset: session.offset })
|
|
101
|
+
} else if (req.method === 'POST' && match[2]) {
|
|
102
|
+
if (session.offset !== session.size) throw fail('文件尚未上传完整', 409)
|
|
103
|
+
const storedName = `${new Date().toISOString().replace(/[:.]/g, '-')}-${id}-${session.name}`
|
|
104
|
+
const absolutePath = path.join(uploadsDir, storedName)
|
|
105
|
+
await fs.rename(session.partialPath, absolutePath)
|
|
106
|
+
sessions.delete(id)
|
|
107
|
+
const file = {
|
|
108
|
+
id: `upload-${id}`, name: session.name, storedName, size: session.size,
|
|
109
|
+
mimeType: session.mimeType, absolutePath,
|
|
110
|
+
relativePath: path.relative(baseDir, absolutePath) || storedName,
|
|
111
|
+
url: `/api/uploads/${encodeURIComponent(storedName)}`,
|
|
112
|
+
};
|
|
113
|
+
reply(res, { ok: true, file: finalize ? await finalize(file, url) : file });
|
|
114
|
+
} else throw fail('不支持的上传操作', 405)
|
|
115
|
+
} finally {
|
|
116
|
+
session.busy = false
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!res.destroyed && !res.headersSent) reply(res, { ok: false, error: error.message || '上传失败' }, error.status || 500)
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
handle.close = () => clearInterval(cleanup)
|
|
124
|
+
return handle
|
|
125
|
+
}
|
package/core-runtime.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export const WebRepl = webModule.WebRepl;
|
|
|
23
23
|
export const WebRuntimeRouter = webModule.WebRuntimeRouter;
|
|
24
24
|
export const createWebRuntime = webModule.createWebRuntime;
|
|
25
25
|
export const runWebServer = webModule.runWebServer;
|
|
26
|
+
export const handleWebRequest = webModule.handleWebRequest;
|
|
26
27
|
export const coreRuntimeInfo = Object.freeze({
|
|
27
28
|
source,
|
|
28
29
|
version: await readCoreVersion(),
|