pi-web-ui 0.77.0 → 0.78.0
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/CHANGELOG.md +27 -1
- package/dist/server/index.js +82 -1
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-CDsBHVQ5.js → TerminalPanel-DCQcbf-J.js} +1 -1
- package/web/dist/assets/{index-DeuPu917.css → index-CaC1cuAF.css} +1 -1
- package/web/dist/assets/index-SREmjZgY.js +346 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BsATrxLx.js +0 -346
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,31 @@
|
|
|
12
12
|
|
|
13
13
|
暂无未发布内容。
|
|
14
14
|
|
|
15
|
+
## [0.78.0] — 2026-09-11
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- 文件预览支持渲染 HTML(`README.html` 这类文件不再只看到源码):打开 `.html` / `.htm` / `.xhtml` 默认是**渲染视图**,工具栏的 👁/`</>` 与 Markdown 一样一键切源码,进编辑态自动落到源码。渲染走**沙箱 iframe**,页面里的 JavaScript 默认**不执行**(工具条显示「🛡 脚本已禁用(静态预览)」),要跑脚本得对**当前这个文件**点「启用脚本」显式放开(切文件即复位、不持久化、不写进任何配置);无论开关如何,iframe 一律**不带 `allow-same-origin`** —— 页面拿不到本应用的同源/DOM/cookie/存储,表单提交与顶层跳转同样被挡(脚本开时工具条换成「⚠ 脚本已启用」并说明后果)。渲染地址是新增的目录映射路由,页面里的**相对引用**(`<link href="../web/src/styles.css">`、`./app.js`、图片…)按浏览器正常语义解析加载:
|
|
20
|
+
- `/api/preview/<工作区相对路径>`(机器浏览的绝对路径用 `__abs__/` 前缀),各路径段 URI 编码;`.html` 文档下发 `Content-Security-Policy: sandbox`(`?allowJs=1` 时 `sandbox allow-scripts`,**永不**加 `allow-same-origin`)与 `X-Content-Type-Options: nosniff`,其余子资源按真实 content-type 直送;`..` 越界由 `workspacePath()` 拒绝(400 `path outside workspace`)。
|
|
21
|
+
- `/api/file` 直出的 HTML 也带上 `sandbox` CSP——把预览地址单独在新标签页打开,一样拿不到应用源。
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 重试与提示词模板「直发」补记模型使用次数:这两个入口都是「沿用当前模型再发一轮」,之前不计入 `model-usage`,模型下拉的「按使用次数排序」会漏掉这部分(现在与正常发送一致;模板直发记的是当前模型)。
|
|
26
|
+
- `vscode-editor` 插件中止上传时临时文件可能残留(Windows):`abortUploadEntry` 旧写法是 `void fh.close()` 后立刻 `unlink` 并把错误吞掉,而 close 是异步的 —— 句柄还没关就删会 `EBUSY/EPERM`,目标目录里就留下 `.vsc-upload-*.part`。现在改成 `await close()` → `await unlink()`,且 `upload_abort` 等清理完再回响应(客户端随后就会去核验目录);定时清扫与 `deactivate` 两条路径改为不等(`void`)。
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- 排队/插队消息改成和正式用户消息**同一套气泡**(`MessageList.tsx` 的 `QueuedMessage` 复用 `.msg-user` 结构):Markdown 渲染(代码块/列表/链接等不再是一坨纯文本)、角色行显示「你」、状态 tag(插队/排队)与移除 ✕ 排在同一行;未发送仍用**虚线边框 + 0.75 透明度**区分,服务端真正下发后直接变成正常消息气泡(外观不再跳变)。`styles.css` 里旧的 `.queued-bubble` / `.queued-text` / `.queued-remove` 一套样式一并删除。
|
|
31
|
+
- 官方插件做手机竖屏(≤640px)适配,桌面端表现不变:`db-client`(连接侧栏变左滑抽屉、库表树变可折叠面板、结果表格在容器内横滑、Redis 键列表改上下排、触摸目标加大、输入框提到 16px 防 iOS 聚焦缩放)、`vscode-editor`(文件树变抽屉 + 顶栏 ☰、选中文件自动收起)、`run-trace`(三段改上下堆叠、时间轴压到 200px、回放条允许换行、触屏色块热区加大)、`webmail`、`demo-mailbox`。
|
|
32
|
+
- `docs/architecture-attachments.md` 的文件预览协议补一节「HTML 渲染走目录映射的 HTTP」(沙箱策略与相对引用语义)。
|
|
33
|
+
|
|
34
|
+
<!-- auto-i18n:start -->
|
|
35
|
+
### i18n
|
|
36
|
+
|
|
37
|
+
- 前端新增 key(8):`showHtmlSource`、`showHtmlPreview`、`htmlJsOff`、`htmlJsOffTip`、`htmlJsOn`、`htmlJsOnTip`、`htmlEnableJs`、`htmlDisableJs`
|
|
38
|
+
<!-- auto-i18n:end -->
|
|
39
|
+
|
|
15
40
|
## [0.77.0] — 2026-09-11
|
|
16
41
|
|
|
17
42
|
### Added
|
|
@@ -425,7 +450,8 @@
|
|
|
425
450
|
- 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
|
|
426
451
|
- 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
|
|
427
452
|
|
|
428
|
-
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.
|
|
453
|
+
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.78.0...main
|
|
454
|
+
[0.78.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.78.0
|
|
429
455
|
[0.77.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.77.0
|
|
430
456
|
[0.76.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.76.0
|
|
431
457
|
[0.75.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.75.0
|
package/dist/server/index.js
CHANGED
|
@@ -236,7 +236,12 @@ app.get("/api/file", async (req, res) => {
|
|
|
236
236
|
const name = basename(abs);
|
|
237
237
|
const kind = previewKind(name);
|
|
238
238
|
const isDownload = req.query.download === "1";
|
|
239
|
-
|
|
239
|
+
// HTML files preview through a sandboxed <iframe> in the file modal
|
|
240
|
+
// (FilePreview.tsx). They are text as far as previewKind goes, so
|
|
241
|
+
// allowlist them explicitly here.
|
|
242
|
+
const lower = name.toLowerCase();
|
|
243
|
+
const isHtmlPreview = lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml");
|
|
244
|
+
if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview) {
|
|
240
245
|
res.status(400).end("not a previewable media file");
|
|
241
246
|
return;
|
|
242
247
|
}
|
|
@@ -251,6 +256,20 @@ app.get("/api/file", async (req, res) => {
|
|
|
251
256
|
res.download(abs, name);
|
|
252
257
|
}
|
|
253
258
|
else {
|
|
259
|
+
if (isHtmlPreview) {
|
|
260
|
+
// Sandbox even a top-level navigation to this URL: a workspace
|
|
261
|
+
// HTML file must never get our origin (it could otherwise read
|
|
262
|
+
// the token cookie). The modal iframe carries its own sandbox
|
|
263
|
+
// attribute as well (defense in depth).
|
|
264
|
+
//
|
|
265
|
+
// ?allowJs=1 is the explicit per-file opt-in from the preview
|
|
266
|
+
// modal ("启用脚本"): scripts run, but still in an opaque
|
|
267
|
+
// origin — no DOM/cookie/storage access to our app, no forms,
|
|
268
|
+
// no top-navigation. NEVER add allow-same-origin here.
|
|
269
|
+
const allowJs = req.query.allowJs === "1";
|
|
270
|
+
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
271
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
272
|
+
}
|
|
254
273
|
res.sendFile(abs);
|
|
255
274
|
}
|
|
256
275
|
}
|
|
@@ -258,6 +277,68 @@ app.get("/api/file", async (req, res) => {
|
|
|
258
277
|
res.status(404).end("not found");
|
|
259
278
|
}
|
|
260
279
|
});
|
|
280
|
+
/**
|
|
281
|
+
* Directory-mapped preview: serves a workspace file at a URL that mirrors its
|
|
282
|
+
* directory location, so an HTML preview's RELATIVE subresources
|
|
283
|
+
* (<link href="../web/src/styles.css">, <img src="./x.png">, <script
|
|
284
|
+
* src="./app.js">, …) resolve and load with normal browser semantics. The
|
|
285
|
+
* iframe document URL itself carries the file's directory — no HTML rewriting.
|
|
286
|
+
*
|
|
287
|
+
* /api/preview/<workspace-rel-path>?clientId=…[&allowJs=1]
|
|
288
|
+
* /api/preview/__abs__/<absolute-wire-path>?clientId=…[&allowJs=1]
|
|
289
|
+
* (each path segment URI-encoded; ".." is normalized by the browser before
|
|
290
|
+
* the request is sent, workspace containment is still re-checked here)
|
|
291
|
+
*
|
|
292
|
+
* Same footing as /api/file: workspace containment enforced, HTML documents
|
|
293
|
+
* get a sandboxed CSP (?allowJs=1 relaxes scripts only — never same-origin),
|
|
294
|
+
* everything else streams with its real content type.
|
|
295
|
+
*/
|
|
296
|
+
app.get("/api/preview/*", async (req, res) => {
|
|
297
|
+
try {
|
|
298
|
+
const captured = String(req.params[0] ?? "");
|
|
299
|
+
const ABS_MARKER = "__abs__/";
|
|
300
|
+
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
301
|
+
const cs = cid ? service.get(cid) : undefined;
|
|
302
|
+
const root = cs?.cwd ?? CWD;
|
|
303
|
+
// Express decodes %XX in the wildcard, so this is back to the wire
|
|
304
|
+
// form (filenames never contain "/", so per-segment encoding from
|
|
305
|
+
// the client round-trips exactly).
|
|
306
|
+
let abs;
|
|
307
|
+
if (captured === "__abs__" || captured.startsWith(ABS_MARKER)) {
|
|
308
|
+
// Machine browsing: absolute wire path ("C:/..." / "/...").
|
|
309
|
+
const wire = captured.slice(ABS_MARKER.length);
|
|
310
|
+
if (!isAbsoluteWirePath(wire)) {
|
|
311
|
+
res.status(400).end("bad absolute preview path");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
abs = wireToAbs(wire);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const wp = workspacePath(root, captured);
|
|
318
|
+
if (!wp) {
|
|
319
|
+
res.status(400).end("path outside workspace");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
abs = wp.abs;
|
|
323
|
+
}
|
|
324
|
+
const name = basename(abs);
|
|
325
|
+
const st = await stat(abs);
|
|
326
|
+
if (!st.isFile()) {
|
|
327
|
+
res.status(400).end("not a file");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
331
|
+
const lower = name.toLowerCase();
|
|
332
|
+
if (lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml")) {
|
|
333
|
+
const allowJs = req.query.allowJs === "1";
|
|
334
|
+
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
335
|
+
}
|
|
336
|
+
res.sendFile(abs);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
res.status(404).end("not found");
|
|
340
|
+
}
|
|
341
|
+
});
|
|
261
342
|
// Production: serve the built frontend from web/dist. Resolve relative to this
|
|
262
343
|
// module so it works when installed as a package (global/npx/Docker), not just
|
|
263
344
|
// from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as l,j as n}from"./markdown-Cpo0pNcR.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-
|
|
1
|
+
import{a as l,j as n}from"./markdown-Cpo0pNcR.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-SREmjZgY.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-CtudoG1_.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
|
|
2
2
|
`)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
|
|
3
3
|
`),exitCode:c}}function ce({conversationId:t,terminalId:c,command:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
|
|
4
4
|
\x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r
|