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 CHANGED
@@ -1,244 +1,62 @@
1
- # maker
1
+ # Neo Web
2
2
 
3
- 一个 Render 风格的 Vue 3 + Vite 单页应用,面向设计人员和工作流用户封装 `neoctl` 本地 AI Agent 运行时。
3
+ 基于 Vue 3 Vite 的浏览器工作台,使用 Neo Engine 处理对话和工具调用。提供会话管理、图片上传、运行状态查看和插件功能。
4
4
 
5
- ## 全局安装
5
+ ## 安装使用
6
6
 
7
- ```bash
7
+ 需要 Node.js 20+。
8
+
9
+ ```sh
8
10
  npm install -g neoctl-web
9
11
  neow
10
12
  ```
11
13
 
12
- `neow` 会启动内置核心、Web 后台和已构建的 Vue 页面;默认使用 `5173` `3101`,端口被占用时自动顺延。运行 `neow --help` 查看全部选项。
14
+ `neow` 自动打开浏览器,默认地址为 `http://127.0.0.1:5173`,端口占用时自动顺延。在页面中填写模型 API 地址、密钥和模型名称即可开始对话。
13
15
 
14
- 当前版本目标:先复刻 `neo web` 的能力;绘图工具等待后续 `neoctl` 更新后再接入。
16
+ ## 源码开发
15
17
 
16
- ## 开发启动
18
+ 在仓库根目录执行:
17
19
 
18
- ```bash
19
- npm run dev
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
- 模型配置页可填写 CPA 管理地址和密码;配置成功后,右侧栏显示 Codex 凭据的周额度。配置默认保存在用户数据目录的 `cpa-config.json`,也可通过 `NEO_CPA_CONFIG_FILE` 指定路径。
28
+ ## 生产启动
61
29
 
62
- Vite 会把以下路径代理到 Neo 运行时,确保本应用使用与 `neo web` 相同的后端能力:
30
+ `web/` 目录执行:
63
31
 
64
- - `/events`:SSE 流式同步
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` 会先自动执行 `npm run build`,再启动 `server.mjs`。请不要直接复用旧 `dist` 目录或只执行 `node server.mjs`,否则部署版可能继续运行旧的前端构建产物。
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
- 默认扫描 `plugins/*/neo-plugin.json`。每个插件目录结构如下:
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
- 仓库自带的插件资源为 `downloads` `xhs-artifact`。新增或移除符合协议的插件目录后重启后台即可更新目录。`GET /api/plugins` 返回全局状态,`GET/POST /api/session-plugins` 管理当前会话状态。
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
- `neoctl` 会读取当前目录 `.env`、用户级配置或 `NEO_ENV_FILE` 指定的配置文件。
50
+ ## 开发命令
235
51
 
236
- 项目提供 `.env.neo.example` 作为示例。需要项目级配置时:
52
+ 以下命令在 `web/` 目录执行:
237
53
 
238
- ```bash
239
- copy .env.neo.example .env
54
+ ```sh
55
+ npm run build # 构建前端
56
+ npm test # 非浏览器测试,需先构建本地 Engine
57
+ npm run test:server # 服务启动与模型配置回归
240
58
  ```
241
59
 
242
- 然后编辑 `.env` 中的模型供应商、API Key、Base URL 和模型名,也可以在单页应用的“模型配置”页面中配置。
60
+ 页面源码在 `src/`,服务入口为 `server.mjs`,插件在 `plugins/`,测试在 `tests/`。
243
61
 
244
- > 注意:`neoctl` 是 Node.js/CLI 运行时依赖,包含文件系统、命令执行、终端/本地 Web UI 等能力,不应直接 import 到 Vue 浏览器端组件。本项目通过“本地运行时 + Vite 代理”的方式集成。
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(),