@gavinc-cn/touchstone-dsh 0.3.0 → 0.4.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/backend/lib/board.js +84 -11
- package/backend/lib/chat.js +37 -0
- package/backend/lib/feishu.js +10 -1
- package/backend/lib/feishu_cfg.js +36 -2
- package/backend/lib/feishu_inbound.js +409 -6
- package/backend/lib/feishu_provision.js +37 -6
- package/backend/lib/lock.js +32 -11
- package/backend/lib/proc.js +91 -8
- package/backend/lib/runner.js +32 -6
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/serverstate.js +5 -15
- package/backend/routes/board.js +9 -1
- package/backend/routes/feishu_app.js +41 -7
- package/backend/routes/index.js +3 -2
- package/backend/routes/projects.js +81 -2
- package/backend/routes/sessions.js +61 -4
- package/backend/server.js +18 -11
- package/board.py +66 -12
- package/chat.py +33 -0
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +46 -5
- package/dsh-plugin/src/client.js +111 -2
- package/feishu.py +417 -3
- package/package.json +1 -1
- package/server.py +155 -11
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +0 -1
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +0 -484
package/backend/server.js
CHANGED
|
@@ -53,6 +53,7 @@ import { setMediaFetcher } from './lib/sessparse.js';
|
|
|
53
53
|
import { pidAlive } from './lib/proc.js';
|
|
54
54
|
import { respondError, respondUnsupportedMethod, sendRedirect } from './lib/http.js';
|
|
55
55
|
import { acquireSingleInstance, resolveDbPath } from './lib/lock.js';
|
|
56
|
+
import { seedHint } from './lib/seed_hint.js';
|
|
56
57
|
import {
|
|
57
58
|
Runner, reconcileUnits, setBoardFinishHook, setBoardSelfcheckHooks, setInstance,
|
|
58
59
|
setLivenessProbe, setUnitHandlers, startUnitSelfcheck,
|
|
@@ -272,11 +273,9 @@ async function main() {
|
|
|
272
273
|
const executedSteps = boot.executed.length + (again ? again.executed.length : 0);
|
|
273
274
|
db = openDb(dbPath);
|
|
274
275
|
purgeExpiredSessions(db);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
+ ',首次登录须修改;仅本次显示,请立即记录)'
|
|
279
|
-
: ' (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)';
|
|
276
|
+
// 横幅文案:三档与 seedAdmin 的口令来源一一对应(见 lib/seed_hint.js;
|
|
277
|
+
// 2026-10-16 前空口令档误落「初始口令取自 TS_ADMIN_PASSWORD」文案)
|
|
278
|
+
const hint = seedHint(boot);
|
|
280
279
|
// 文案只报「检查了几步」而不说「执行了几步」(M3 评审 D5):声明式迁移在**已迁移完**的库上
|
|
281
280
|
// 逐步 no-op(实测同一份快照库起停两次,表行数与文件 sha256 完全不变),旧文案
|
|
282
281
|
// 「本次执行迁移 N 步」读起来像「又迁移了一遍」,排障时会误导。
|
|
@@ -312,20 +311,28 @@ async function main() {
|
|
|
312
311
|
setBoardSelfcheckHooks({ unitManaged: board.unitManaged, refreshExtRows: board.refreshExtRows });
|
|
313
312
|
const runnerInstance = new Runner(true, { dbPath });
|
|
314
313
|
setInstance(runnerInstance);
|
|
315
|
-
// ---- M6.5 接线:单元执行体(`setUnitHandlers
|
|
316
|
-
//
|
|
317
|
-
// fix / pipeline /
|
|
318
|
-
// `
|
|
314
|
+
// ---- M6.5/M10 接线:单元执行体(`setUnitHandlers`)--------------------------------
|
|
315
|
+
// 三路**接真**:`t:` → `processTask`(轮次执行体:script_retest / 复压 / retest_bug /
|
|
316
|
+
// fix / pipeline / 轮次循环)、`c:` → `processCard`(看板卡片起跑)、
|
|
317
|
+
// `a:` → `board.deliverAnswerUnit`(答案送达;`runner.py:1087` → `board._deliver_answer_unit`)。
|
|
318
|
+
// 接线前三路一律抛具名错误 `UNIT_HANDLER_MISSING` ⇒ 实例级判据
|
|
319
319
|
// `cases-http/m6_5_runner.json` 接线前是**红**的(rounds 缺行 + error 文案不同),接线后才绿。
|
|
320
320
|
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
321
|
+
// **`a:` 是 2026-10-16 补接的(M10)**:M6.5 只接了 `t:`/`c:` 并把 `a:` 注明「属 M7」,
|
|
322
|
+
// 但 M7/M7.5/M8/M9 都没接 ⇒ 平台侧作答(TS 会话窗 / 飞书入站;「立即送达」以外的自动路径)
|
|
323
|
+
// 一律「入队成功、永远送不出去」:`runPickedUnit` 的 a: except 分支是「放回等待重投」,
|
|
324
|
+
// 于是**热旋**——真机实测后端 50% CPU、日志 5.5KB/s 涨到 3.9MB(几十万行同一句
|
|
325
|
+
// 「答案送达体未移植(M5)」,而实现体 M5 就在 `board.js` 里,缺的只是这张注册表)。
|
|
326
|
+
// 用户可见现象:在 TS 里答了没反应、答案永远停在「排队中」。与 `routes/sessions.js`
|
|
327
|
+
// 误用 M3 占位桩同属「已实现但没接线」类缺陷(判据缺口也相同:函数级单测照不到接线)。
|
|
323
328
|
//
|
|
324
329
|
// 位置:`setInstance` 之后、`recover()` 之前 —— 执行体在闸后(`releaseBootGate`)才可能被
|
|
325
330
|
// 调用,此处注册只保证「任何一条被拾取的单元都有执行体」,不引入新的启动序。
|
|
326
331
|
setUnitHandlers({
|
|
327
332
|
processTask: (taskId) => runnerInstance.processTask(taskId),
|
|
328
333
|
processCard: (cardId) => runnerInstance.processCard(cardId),
|
|
334
|
+
// `options` 与同段其它 board 口同款:显式传本轮解析出的 `dbPath`(缺省库口径见 db.js)
|
|
335
|
+
deliverAnswerUnit: (cardId) => board.deliverAnswerUnit(cardId, { dbPath }),
|
|
329
336
|
});
|
|
330
337
|
await runnerInstance.recover();
|
|
331
338
|
await board.recover({ dbPath }); // ③ 硬顺序:runner.recover → board.recover → 对账
|
package/board.py
CHANGED
|
@@ -1557,6 +1557,11 @@ _runs_lock = threading.Lock()
|
|
|
1557
1557
|
# 单卡片 prompt 长度上限(与 chat.MESSAGE_MAX 对齐)
|
|
1558
1558
|
PROMPT_MAX = 20000
|
|
1559
1559
|
|
|
1560
|
+
# 平台任务提示词的段标(`build_task_prompt` 的首段)。除拼提示词外还用作**判据**:
|
|
1561
|
+
# 会话首问以它开头 ⇒ 这个会话是平台按某张卡片的内容拼提示词启动的(2026-10-16 起,
|
|
1562
|
+
# 这类卡的标题/描述也跟随 DSH,见 `_is_platform_task_session`)。
|
|
1563
|
+
TASK_PROMPT_TAG = "【任务描述】"
|
|
1564
|
+
|
|
1560
1565
|
|
|
1561
1566
|
def build_task_prompt(project, card, extra=""):
|
|
1562
1567
|
"""全量任务提示词:任务描述(卡片标题并入描述)+ 工作目录 + 附加段。
|
|
@@ -1580,7 +1585,7 @@ def build_task_prompt(project, card, extra=""):
|
|
|
1580
1585
|
desc = (card["description"] or "").strip()
|
|
1581
1586
|
body = "\n\n".join(seg for seg in (title, desc) if seg) or "(无)"
|
|
1582
1587
|
workspace = card_workspace(project, card)
|
|
1583
|
-
p = (f"
|
|
1588
|
+
p = (f"{TASK_PROMPT_TAG}{body}\n"
|
|
1584
1589
|
f"【工作目录】{workspace}")
|
|
1585
1590
|
wt = (_card_opt(card, "worktree") or "").strip()
|
|
1586
1591
|
if wt:
|
|
@@ -2304,6 +2309,17 @@ def compact_session(project, card, sid):
|
|
|
2304
2309
|
外部会话的 agent 已在宿主 store 注册,`/session` resume 必撞
|
|
2305
2310
|
`agent "…" is already registered` ⇒ 500(C 批 T1 实测)。
|
|
2306
2311
|
|
|
2312
|
+
**会话存活前置(2026-10-17 修卡 907「TS 侧 /compact 对已结束会话必失败」)**:
|
|
2313
|
+
上面那条「不能先接管」只对**宿主里仍活着**的会话成立;对宿主里**根本没有活 agent**
|
|
2314
|
+
的会话(dsh web 重启、插件重载、久未接续的旧卡——用户报障卡 907 就是 9 天前的
|
|
2315
|
+
平台自建卡),驱动 `/compact` 必 404「会话已结束(宿主无活动 agent)」,上面两跳
|
|
2316
|
+
再怎么补声明也救不回来。而 dsh GUI 自己的 `/compact` 对冷会话是**按需 resume 再执行
|
|
2317
|
+
命令**(`dsh-api-session-controller.resolve` → `agents.resume`),故这里与投递腿
|
|
2318
|
+
`_deliver_now` 同款:先 `_ensure_card_session` 接回(判据/边界同它——中枢未对齐按
|
|
2319
|
+
「未知」不动、sync 卡不收养、回滚阀 `TS_HOST_SESSION_REVIVE=0` 时不动,均为零请求),
|
|
2320
|
+
再走下面的压缩两跳。接不回**不在此收口**:照旧走两跳,由驱动 404 文案兜底(与修复前
|
|
2321
|
+
完全一致)。
|
|
2322
|
+
|
|
2307
2323
|
sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
|
|
2308
2324
|
RuntimeError(server 转 400)。
|
|
2309
2325
|
"""
|
|
@@ -2314,6 +2330,8 @@ def compact_session(project, card, sid):
|
|
|
2314
2330
|
# 单族化:非 dsh 即退场族项目(migrate_p7b 改写后不再出现),
|
|
2315
2331
|
# 给「改绑 dsh 插件」的明确提示(B0 防呆口径)
|
|
2316
2332
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
2333
|
+
# 会话存活前置:宿主已失去该会话 ⇒ 先按 resume 语义接回,再压缩(与投递同款)
|
|
2334
|
+
_ensure_card_session(project, card, sid)
|
|
2317
2335
|
# 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
|
|
2318
2336
|
# 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
|
|
2319
2337
|
# 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
|
|
@@ -3450,6 +3468,9 @@ SYNC_BUSY_MTIME_S = 120
|
|
|
3450
3468
|
# 用户改名后,同步节拍跟着改卡标题)、描述 = 主会话第一次用户提问**全文**。
|
|
3451
3469
|
# 旧口径(2026-10-07~10-10:「首问首行进标题、其余行进描述」)已退场,存量卡由
|
|
3452
3470
|
# `_sync_card_follow` 在首个同步拍回填(详见 board spec §48)。
|
|
3471
|
+
# 2026-10-16 扩面(用户诉求「卡面标题/正文与 DSH 保持一致」):同一口径从
|
|
3472
|
+
# `origin='sync'` 卡扩到**平台从看板启动过会话的卡**(判据 = 会话首问是平台拼的
|
|
3473
|
+
# 任务提示词,见 `_is_platform_task_session`);列语义与存量收口仍只服务 sync 卡。
|
|
3453
3474
|
SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
|
|
3454
3475
|
SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
|
|
3455
3476
|
|
|
@@ -3611,19 +3632,45 @@ def _auto_title_forms(item, sid):
|
|
|
3611
3632
|
return forms
|
|
3612
3633
|
|
|
3613
3634
|
|
|
3635
|
+
def _in_first_prompt(text, item):
|
|
3636
|
+
"""卡面字段是否仍是「平台按卡片内容拼进首问」的**原文形态**(判自动写入用)。
|
|
3637
|
+
|
|
3638
|
+
平台起会话时把卡片标题/描述原样拼进首问(`build_task_prompt`:标题并入
|
|
3639
|
+
【任务描述】段、与描述空行分隔)⇒ 卡面字段只要没被用户改过,就必然能在首问
|
|
3640
|
+
原文里原样找到。用户后来改写过的文本通常不在(⇒ 不覆盖,尊重用户编辑)。
|
|
3641
|
+
空/纯空白一律 False(空标题由 `_auto_title_forms` 那一档负责)。
|
|
3642
|
+
"""
|
|
3643
|
+
t = (text or "").strip()
|
|
3644
|
+
return bool(t) and t in (item.get("first_prompt") or "")
|
|
3645
|
+
|
|
3646
|
+
|
|
3647
|
+
def _is_platform_task_session(item):
|
|
3648
|
+
"""该会话是否「平台按某张卡片的内容拼提示词启动的」(判据 = 首问以段标开头)。
|
|
3649
|
+
|
|
3650
|
+
卡片启动腿(`enter_doing` → `build_task_prompt`)拼的首问以 `TASK_PROMPT_TAG`
|
|
3651
|
+
开头;用户在 dsh GUI 里直跑的会话首问是用户原文、续跑腿是「继续」,都不会命中。
|
|
3652
|
+
用于把正向同步(DSH 标题/首问 → 卡面)从 sync 卡扩到**平台从看板启动过会话的卡**
|
|
3653
|
+
(2026-10-16 用户诉求:卡面标题/正文与 DSH 保持一致;见 spec/board §48)。
|
|
3654
|
+
"""
|
|
3655
|
+
return (item.get("first_prompt") or "").lstrip().startswith(TASK_PROMPT_TAG)
|
|
3656
|
+
|
|
3657
|
+
|
|
3614
3658
|
def _sync_card_follow(card, item, sid):
|
|
3615
|
-
"""
|
|
3659
|
+
"""卡面按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
|
|
3616
3660
|
|
|
3617
3661
|
只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
|
|
3618
|
-
|
|
3619
|
-
会话标题事件 /
|
|
3620
|
-
|
|
3621
|
-
|
|
3662
|
+
只在卡仍由平台自动写入时动——卡标题 ∈ `_auto_title_forms`(空 / sid 短码 / 任一枚
|
|
3663
|
+
会话标题事件 / 旧口径的首问首行)**或** 仍是平台拼进首问的原文(`_in_first_prompt`,
|
|
3664
|
+
2026-10-16 增:服务「平台从看板启动过会话的卡」——它的标题是用户在卡里敲的原文,
|
|
3665
|
+
不在任何标题事件里);用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动)。
|
|
3666
|
+
描述仅在为空、仍是**旧口径自动写入**(首问除首行)、或仍是平台拼进首问的原文时
|
|
3667
|
+
改成首问全文;用户写过的描述不覆盖。
|
|
3622
3668
|
"""
|
|
3623
3669
|
if (card["session_id"] or sid) != sid:
|
|
3624
3670
|
return {}
|
|
3625
3671
|
cur_title = (card["title"] or "").strip()
|
|
3626
|
-
if cur_title not in _auto_title_forms(item, sid)
|
|
3672
|
+
if (cur_title not in _auto_title_forms(item, sid)
|
|
3673
|
+
and not _in_first_prompt(cur_title, item)):
|
|
3627
3674
|
return {}
|
|
3628
3675
|
title, desc = _sync_card_fields(item, sid)
|
|
3629
3676
|
out = {}
|
|
@@ -3631,7 +3678,8 @@ def _sync_card_follow(card, item, sid):
|
|
|
3631
3678
|
out["title"] = title
|
|
3632
3679
|
cur_desc = (card["description"] or "").strip()
|
|
3633
3680
|
legacy_desc = _split_first_prompt(item.get("first_prompt") or "")[1]
|
|
3634
|
-
if desc and cur_desc != desc
|
|
3681
|
+
if (desc and cur_desc != desc
|
|
3682
|
+
and (cur_desc in ("", legacy_desc) or _in_first_prompt(cur_desc, item))):
|
|
3635
3683
|
out["description"] = desc
|
|
3636
3684
|
return out
|
|
3637
3685
|
|
|
@@ -3653,6 +3701,10 @@ def sync_sessions(project):
|
|
|
3653
3701
|
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
|
|
3654
3702
|
会话的存量 sync 卡收口进回收站(每项目一轮);上述两类误建卡的存量也按同款
|
|
3655
3703
|
口径收口(每项目一轮,见函数尾注释)。
|
|
3704
|
+
**平台从看板启动过会话的卡**(用户建卡 → 开始/回车启动,首问 = 平台拼的任务提示词,
|
|
3705
|
+
见 `_is_platform_task_session`)同样按上面同一口径补齐卡面——但**列语义不动**:
|
|
3706
|
+
归档/取消归档的列映射与各类存量收口仍只服务 `origin='sync'` 卡(用户自己的卡,
|
|
3707
|
+
平台只同步标题/描述,不替用户搬列、不软删)。用户改过卡名 ⇒ 整卡不动(含描述)。
|
|
3656
3708
|
sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
|
|
3657
3709
|
"""
|
|
3658
3710
|
if not settings_of(project["id"]).get("sync_sessions", True):
|
|
@@ -3699,13 +3751,15 @@ def sync_sessions(project):
|
|
|
3699
3751
|
db.update_board_card(cid, session_id=sid, sessions=json.dumps([sid]),
|
|
3700
3752
|
column_key=col, origin="sync")
|
|
3701
3753
|
created.append(cid)
|
|
3702
|
-
elif c["origin"] == "sync":
|
|
3703
|
-
#
|
|
3704
|
-
#
|
|
3754
|
+
elif c["origin"] == "sync" or _is_platform_task_session(it):
|
|
3755
|
+
# 卡面补齐(2026-10-07 起服务 sync 卡;2026-10-16 起扩到「平台从看板启动过
|
|
3756
|
+
# 会话的卡」,见 `_sync_card_follow`):标题/描述仍是平台自动写入形态时按
|
|
3757
|
+
# DSH 当前标题 + 首问全文覆盖(用户改过名整卡不动)
|
|
3705
3758
|
fields = _sync_card_follow(c, it, sid)
|
|
3706
3759
|
if fields:
|
|
3707
3760
|
db.update_board_card(c["id"], **fields)
|
|
3708
|
-
if c["column_key"] in ("doing", "review")
|
|
3761
|
+
if (c["origin"] == "sync" and c["column_key"] in ("doing", "review")
|
|
3762
|
+
and not _has_active_run(c["id"])):
|
|
3709
3763
|
# dsh 的 busy/空闲 列映射已移交调和器(事件驱动,异常不搬列);
|
|
3710
3764
|
# 此处保留归档→done(archived 来源=list_sessions 的宿主归档集文件读,
|
|
3711
3765
|
# 与调和器读驱动帧是两条独立通道:文件读不依赖驱动链路,重启/断连时
|
package/chat.py
CHANGED
|
@@ -399,6 +399,39 @@ def cancel_queued(sid=None, card_id=None):
|
|
|
399
399
|
return sum(1 for r in rows if cancel(r["id"]))
|
|
400
400
|
|
|
401
401
|
|
|
402
|
+
def cancel_queued_detail(sid=None, card_id=None, msg_id=None):
|
|
403
|
+
"""取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
|
|
404
|
+
|
|
405
|
+
与 `cancel_queued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
|
|
406
|
+
前端要把消息原文放回输入框,而 `msgs_of_sid` 下发给前端的展示文本被截到 120 字——
|
|
407
|
+
回填必须由服务端给**全量原文**,否则长消息被静默截断。
|
|
408
|
+
|
|
409
|
+
逐行只在 `cancel()` 真命中(chat_msgs 的 queued→cancelled 原子守卫是仲裁点,R12)
|
|
410
|
+
时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
|
|
411
|
+
前端不会把仍在执行的消息错放回输入框。返回按入队顺序(msg_rows 的 created_at,id)。
|
|
412
|
+
|
|
413
|
+
`msg_id` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
|
|
414
|
+
返回 `{"cancelled": n, "msgs": [{"id", "text"}]}`;空过滤返回空(不误伤全库)。
|
|
415
|
+
"""
|
|
416
|
+
if not sid and not card_id and not msg_id:
|
|
417
|
+
return {"cancelled": 0, "msgs": []}
|
|
418
|
+
if msg_id:
|
|
419
|
+
row = waitq.msg_get(msg_id)
|
|
420
|
+
if row is None or row["state"] != STATE_QUEUED:
|
|
421
|
+
return {"cancelled": 0, "msgs": []}
|
|
422
|
+
rows = [row]
|
|
423
|
+
else:
|
|
424
|
+
rows = waitq.msg_rows(sid=sid, card_id=card_id, states=(STATE_QUEUED,))
|
|
425
|
+
msgs = []
|
|
426
|
+
for r in rows:
|
|
427
|
+
# 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
|
|
428
|
+
text = r["message"] if r["message"] is not None else ""
|
|
429
|
+
if not cancel(r["id"]):
|
|
430
|
+
continue
|
|
431
|
+
msgs.append({"id": r["id"], "text": str(text)})
|
|
432
|
+
return {"cancelled": len(msgs), "msgs": msgs}
|
|
433
|
+
|
|
434
|
+
|
|
402
435
|
def msgs_of_sid(sid):
|
|
403
436
|
"""本会话消息的展示视图(排队/执行中 + 近期失败):{id,text,state,error,created_at}。
|
|
404
437
|
|
|
@@ -279,10 +279,117 @@ function panelEntry() {
|
|
|
279
279
|
return '/touchstone/app';
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
//
|
|
282
|
+
// ── 窗口装饰区让位(2026-10-17, 卡 987 方案 A) ─────────────────────────────────
|
|
283
|
+
// 血案: 面板是全窗口浮层(position:fixed; inset:0), 而桌面版窗口的顶带(Windows 的最小化/
|
|
284
|
+
// 最大化/关闭、macOS 的红绿灯)由桌面壳画在网页之上 —— 浮层顶部那行按钮正好落在它底下,
|
|
285
|
+
// 在 dsh desktop 里整块被盖住、点不到(现象与取证见
|
|
286
|
+
// doc_ai/debug_runtime/202610/20261017_0600_零项目首屏无可点建项目入口… .md)。
|
|
287
|
+
// 修法: 让整个浮层从窗口顶边**下移**「壳实际绘制的窗口 chrome 高度」—— 顶带完整露给壳
|
|
288
|
+
// (标题文字/拖拽区/窗口按钮都还在原位), 面板内容从它下面开始。等价于 dsh 自己主面板与
|
|
289
|
+
// 弹窗的处理口径(layout 发布 --dsh-frame-top-clearance / --dsh-frame-chrome-top),
|
|
290
|
+
// 而且**不需要 SPA 侧改任何代码**: iframe 拿到的视口自己就变矮了。
|
|
291
|
+
// 高度来源(优先级从高到低; 全部读不到 ⇒ 0 ⇒ 独立浏览器形态, 行为与今天完全一致):
|
|
292
|
+
// ① dsh 桌面壳发布在 <html> 上的约定量(见 @deepseek-ai/dsh-client-ui-layout 的
|
|
293
|
+
// README「窗口 chrome 顶带」; 普通浏览器文档不发布这些值):
|
|
294
|
+
// --dsh-frame-chrome-top 壳当前**实际绘制**的顶带高(全屏时归 0)
|
|
295
|
+
// --dsh-frame-top-clearance 顶带之下的固定下沉量(Windows = 标题栏高, macOS = 48px)
|
|
296
|
+
// **取两者较大**: 全屏时 chrome-top 归零, 但 dsh 的 Frame 仍按 titlebar 高度让位,
|
|
297
|
+
// 取 max 才能和主面板同口径、不会在全屏时又把窗口按钮盖回去。
|
|
298
|
+
// ② WCO 原生接口 navigator.windowControlsOverlay(Chromium/Electron; 旧桌面壳只开了
|
|
299
|
+
// overlay 但没发布 ① 的量时兜底): getTitlebarAreaRect() 的 y + height = 顶带高。
|
|
300
|
+
// ③ CSS env(titlebar-area-height) 探针(同一能力的老式访问面, 只支持 env 的壳兜底)。
|
|
301
|
+
// 仍读不到(普通浏览器/不支持窗口浮层的壳) ⇒ 0 ⇒ 现状(独立形态本来就没有窗口装饰)。
|
|
302
|
+
const CHROME_MAX_PX = 200; // 合法上限: 再大必然是脏值(标题栏不可能 200px), 一口拒绝
|
|
303
|
+
|
|
304
|
+
/** 读 <html> 上某个 CSS 自定义属性的像素值。空串/无法解析/越界 ⇒ null(0px 是合法值)。 */
|
|
305
|
+
function readChromePx(name) {
|
|
306
|
+
try {
|
|
307
|
+
if (typeof document === 'undefined' || !document.documentElement) return null;
|
|
308
|
+
const cs = window.getComputedStyle ? window.getComputedStyle(document.documentElement) : null;
|
|
309
|
+
if (!cs) return null;
|
|
310
|
+
const raw = cs.getPropertyValue(name);
|
|
311
|
+
if (!raw) return null;
|
|
312
|
+
const n = parseFloat(raw);
|
|
313
|
+
if (!Number.isFinite(n) || n < 0 || n > CHROME_MAX_PX) return null;
|
|
314
|
+
return n;
|
|
315
|
+
} catch { return null; } // 读样式异常绝不能拖垮面板本体
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** WCO 原生接口量顶带高(标题栏区域 rect 的 y + height); 不可见/不可用 ⇒ null。 */
|
|
319
|
+
function wcoChromePx() {
|
|
320
|
+
try {
|
|
321
|
+
const nav = window.navigator;
|
|
322
|
+
const wco = nav && nav.windowControlsOverlay;
|
|
323
|
+
if (!wco || !wco.visible) return null;
|
|
324
|
+
const rect = wco.getTitlebarAreaRect ? wco.getTitlebarAreaRect() : null;
|
|
325
|
+
if (!rect) return null;
|
|
326
|
+
const n = (rect.y || 0) + rect.height;
|
|
327
|
+
if (!Number.isFinite(n) || n <= 0 || n > CHROME_MAX_PX) return null;
|
|
328
|
+
return n;
|
|
329
|
+
} catch { return null; }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** env(titlebar-area-height) 探针: 挂一个 0 宽、高度取 env 的隐形探针量出来; 不支持 ⇒ null。
|
|
333
|
+
* 探针始终会被摘掉(成功/失败都摘), 不往宿主 DOM 里留垃圾。 */
|
|
334
|
+
function envChromePx() {
|
|
335
|
+
try {
|
|
336
|
+
if (typeof document === 'undefined' || !document.body
|
|
337
|
+
|| typeof document.createElement !== 'function'
|
|
338
|
+
|| typeof document.body.appendChild !== 'function') return null;
|
|
339
|
+
const probe = document.createElement('div');
|
|
340
|
+
probe.style.cssText = 'position:fixed;left:0;top:0;width:0;'
|
|
341
|
+
+ 'height:env(titlebar-area-height,0px);visibility:hidden;pointer-events:none';
|
|
342
|
+
document.body.appendChild(probe);
|
|
343
|
+
let h = 0;
|
|
344
|
+
try {
|
|
345
|
+
h = probe.getBoundingClientRect ? probe.getBoundingClientRect().height : 0;
|
|
346
|
+
} finally {
|
|
347
|
+
if (typeof probe.remove === 'function') probe.remove();
|
|
348
|
+
else if (typeof document.body.removeChild === 'function') document.body.removeChild(probe);
|
|
349
|
+
}
|
|
350
|
+
if (!Number.isFinite(h) || h <= 0 || h > CHROME_MAX_PX) return null;
|
|
351
|
+
return h;
|
|
352
|
+
} catch { return null; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 窗口 chrome 顶带高(px): 面板浮层从窗口顶边下移这么多。0 = 没有桌面窗口装饰。 */
|
|
356
|
+
function windowChromeTop() {
|
|
357
|
+
// ① dsh 桌面壳的约定量(两者取大, 口径见本节头注释)
|
|
358
|
+
const chrome = readChromePx('--dsh-frame-chrome-top');
|
|
359
|
+
const clearance = readChromePx('--dsh-frame-top-clearance');
|
|
360
|
+
if (chrome !== null || clearance !== null) {
|
|
361
|
+
return Math.max(chrome || 0, clearance || 0);
|
|
362
|
+
}
|
|
363
|
+
// ②③ 旧壳/其它壳兜底: WCO 原生接口 → env() 探针
|
|
364
|
+
return wcoChromePx() || envChromePx() || 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** chrome 顶带高的响应式读取: 渲染期现读(首帧就带真值, 不闪一帧顶格),
|
|
368
|
+
* 壳换档(resize/进出全屏/窗口几何变化)时自增重渲染。 */
|
|
369
|
+
function useWindowChromeTop() {
|
|
370
|
+
const [, setRevision] = React.useState(0);
|
|
371
|
+
React.useEffect(() => {
|
|
372
|
+
const bump = () => setRevision((n) => n + 1);
|
|
373
|
+
window.addEventListener('resize', bump);
|
|
374
|
+
document.addEventListener('fullscreenchange', bump);
|
|
375
|
+
// WCO 几何变化不是 DOM 事件(壳侧全屏/标题栏变高/贴边), 只能由原生接口通知
|
|
376
|
+
const wco = window.navigator && window.navigator.windowControlsOverlay;
|
|
377
|
+
if (wco && typeof wco.addEventListener === 'function') wco.addEventListener('geometrychange', bump);
|
|
378
|
+
return () => {
|
|
379
|
+
window.removeEventListener('resize', bump);
|
|
380
|
+
document.removeEventListener('fullscreenchange', bump);
|
|
381
|
+
if (wco && typeof wco.removeEventListener === 'function') wco.removeEventListener('geometrychange', bump);
|
|
382
|
+
};
|
|
383
|
+
}, []);
|
|
384
|
+
return windowChromeTop();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 全屏面板: 浮层从窗口 chrome 顶带之下开始(没有桌面壳时 = 0 = 吃满全屏) +
|
|
388
|
+
// 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
|
|
283
389
|
// iframe 同源, /touchstone/app 经薄壳反代到 server.py 的插件版 SPA
|
|
284
390
|
function Panel() {
|
|
285
391
|
const isOpen = useOpen();
|
|
392
|
+
const chromeTop = useWindowChromeTop(); // 窗口装饰顶带高: Windows 标题栏/macOS 红绿灯
|
|
286
393
|
// 保活(2026-10-07): 打开过就在 DOM 里常驻, 关闭只把容器 display:none —— iframe 一旦
|
|
287
394
|
// 从 DOM 摘掉, 内嵌 SPA 整篇文档就没了, 再打开是全新加载(路由/弹窗/滚动/未提交输入全丢)。
|
|
288
395
|
// mounted 由「打开过」与「当前打开」合成: 首次打开的那一次渲染就带上 iframe(不等 effect
|
|
@@ -389,7 +496,9 @@ function Panel() {
|
|
|
389
496
|
const visible = !zoneOk || shown; // 自动隐藏不可用时(zoneOk=false)保持常显
|
|
390
497
|
return h('div', {
|
|
391
498
|
style: {
|
|
392
|
-
|
|
499
|
+
// 顶边让出窗口装饰带(见 useWindowChromeTop): 桌面壳里 = 标题栏/红绿灯的高度,
|
|
500
|
+
// 普通浏览器里 = 0(与旧版 inset:0 完全一致)。其余三边照旧贴边。
|
|
501
|
+
position: 'fixed', top: chromeTop, left: 0, right: 0, bottom: 0, zIndex: 9999,
|
|
393
502
|
background: 'var(--dsw-alias-bg-base, #161617)',
|
|
394
503
|
display: isOpen ? 'block' : 'none', // 关闭 = 只隐藏: iframe 与内嵌 SPA 常驻(保活)
|
|
395
504
|
},
|
package/dsh-plugin/lib/index.js
CHANGED
|
@@ -480,9 +480,32 @@ function renderNodeRuntimeHint({ execPath, version, detail, logPath }) {
|
|
|
480
480
|
* 为什么需要:「入口缺失」只是新开关失败的一种;更常见的是入口在、跑不起来
|
|
481
481
|
* (宿主 Node 版本不认 `--disable-warning`、`dist-plugin` 缺失、被执行策略拦、脚本自身报错)。
|
|
482
482
|
* 那时 `backendPort` 一直是 0,proxy 只会回通用 503 JSON —— 面板里显示一段 JSON 原文,
|
|
483
|
-
*
|
|
483
|
+
* 用户既不知道发生了什么、也不知道去哪看。提示页给四件事:失败原因、**后端最后几行输出**、
|
|
484
|
+
* 日志路径、怎么回退。
|
|
485
|
+
*
|
|
486
|
+
* 2026-10-17 桌面端真机事故的教训:提示页原先只教用户自己 `tail` 日志文件(Windows 用户得先
|
|
487
|
+
* 找到库目录、开终端),而失败原因(「同一数据库已有运行中的实例:pid=…」)就一行字。现改为
|
|
488
|
+
* 把**内存里收到的最后几行**直接内联(见 apply 里的 `crashTail`),并对 `code=3` 给出单实例锁
|
|
489
|
+
* 的专项提示 —— 提示页要能自解释,而不是把排障转嫁给用户。
|
|
484
490
|
*/
|
|
485
|
-
function renderBackendCrashHint({ entryPath, logPath, code, signal }) {
|
|
491
|
+
function renderBackendCrashHint({ entryPath, logPath, code, signal, tailLines }) {
|
|
492
|
+
const logPathText = escapeHtml(logPath || '<库目录>/plugin-backend.log');
|
|
493
|
+
const tail = (Array.isArray(tailLines) ? tailLines : [])
|
|
494
|
+
.map((line) => String(line)).filter((line) => line.trim()).slice(-10);
|
|
495
|
+
const tailBlock = tail.length
|
|
496
|
+
? '<p>后端进程最后输出(内存缓冲;启动失败原因通常就在最后几行):</p>'
|
|
497
|
+
+ '<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;'
|
|
498
|
+
+ `padding:.8rem 1rem;overflow:auto">${escapeHtml(tail.join('\n'))}</pre>`
|
|
499
|
+
: '';
|
|
500
|
+
// code=3 在**两版后端**里都是「同库单实例锁没拿到」(server.py 与 backend/server.js 的
|
|
501
|
+
// exit 3 同义,见 spec §十九.7)。把排障动作写进页面,别让用户对着 generic 文案猜。
|
|
502
|
+
const lockHint = String(code) === '3'
|
|
503
|
+
? '<p><b>code=3 = 同库单实例锁没拿到</b>:可能另有 Touchstone 后端占着同一个库,'
|
|
504
|
+
+ '或锁文件/归属记录残留。先确认没有实例在跑(任务管理器里找带 '
|
|
505
|
+
+ '<code>backend/server.js</code> / <code>server.py</code> 的 node/python 进程),'
|
|
506
|
+
+ '再停用→启用本插件重试;仍失败再把库目录下的 '
|
|
507
|
+
+ '<code>*.lock</code> / <code>*.pid</code> 移走后重试。</p>'
|
|
508
|
+
: '';
|
|
486
509
|
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
487
510
|
+ '<title>Touchstone 后端未启动</title></head>'
|
|
488
511
|
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
@@ -490,10 +513,13 @@ function renderBackendCrashHint({ entryPath, logPath, code, signal }) {
|
|
|
490
513
|
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:Node 后端进程退出</h2>'
|
|
491
514
|
+ `<p>入口 <code>${escapeHtml(entryPath)}</code> 起来了但没有报出监听端口就退出`
|
|
492
515
|
+ `(code=${escapeHtml(String(code))}${signal ? `, signal=${escapeHtml(String(signal))}` : ''})。</p>`
|
|
493
|
-
+
|
|
516
|
+
+ tailBlock
|
|
517
|
+
+ lockHint
|
|
518
|
+
+ '<p>下一步:看完整后端日志里的最后几行(启动失败原因就在那里)——</p>'
|
|
494
519
|
+ `<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;padding:.8rem 1rem;`
|
|
495
|
-
+ `overflow:auto">tail -n 40 ${
|
|
496
|
-
+ '<p>常见原因:<code>dist-plugin</code>
|
|
520
|
+
+ `overflow:auto">tail -n 40 ${logPathText}</pre>`
|
|
521
|
+
+ '<p>常见原因:<code>dist-plugin</code> 构建产物缺失、被执行策略拦下、入口脚本自身报错、'
|
|
522
|
+
+ '同库单实例锁被占/残留(<code>code=3</code>)。'
|
|
497
523
|
+ '若日志里是 <code>node:sqlite</code> / <code>ERR_UNKNOWN_BUILTIN_MODULE</code> 一类,'
|
|
498
524
|
+ '那是宿主 Node 太老 —— 升级到 22.19+ 或改回 <code>backend: python</code>。'
|
|
499
525
|
+ '改完把本插件<b>停用再启用</b>即会重试。</p>'
|
|
@@ -809,8 +835,18 @@ export async function apply(ctx, config = {}) {
|
|
|
809
835
|
try { myLog.write(line + '\n'); } catch { /* 流已坏:忽略,绝不打断读取 */ }
|
|
810
836
|
};
|
|
811
837
|
let sawListen = false; // 是否报出过监听端口(判「起来就崩」用,见 renderBackendCrashHint)
|
|
838
|
+
// 崩溃提示页要内联「最后几行输出」:从管道现收一份内存环形缓冲。为什么不读日志文件——
|
|
839
|
+
// 日志是 append 写流(异步落盘),退出的那一刻盘上可能还差最后几行;只有这里保存的是
|
|
840
|
+
// pumpLines **已经交付**的完整行(2026-10-17 桌面端真机事故:失败原因就一行,但用户拿不到)。
|
|
841
|
+
const CRASH_TAIL_LINES = 12;
|
|
842
|
+
const crashTail = [];
|
|
843
|
+
const keepLine = (line) => {
|
|
844
|
+
crashTail.push(line);
|
|
845
|
+
if (crashTail.length > CRASH_TAIL_LINES) crashTail.shift();
|
|
846
|
+
};
|
|
812
847
|
pumpLines(myChild.stdout, (line) => {
|
|
813
848
|
writeLog(line);
|
|
849
|
+
keepLine(line);
|
|
814
850
|
const m = line.match(/^TOUCHSTONE_LISTEN (\d+)/);
|
|
815
851
|
if (m) {
|
|
816
852
|
sawListen = true;
|
|
@@ -820,6 +856,7 @@ export async function apply(ctx, config = {}) {
|
|
|
820
856
|
});
|
|
821
857
|
pumpLines(myChild.stderr, (line) => {
|
|
822
858
|
writeLog(line);
|
|
859
|
+
keepLine(line);
|
|
823
860
|
if (line.trim()) logger.warn(`touchstone[${backend === BACKEND_NODE ? 'node' : 'py'}]: ${line.trim()}`);
|
|
824
861
|
});
|
|
825
862
|
// 「起来就崩」不再只留一行 dsh 日志:Node 分支给面板一个可读提示页(Python 分支行为不动)。
|
|
@@ -827,6 +864,7 @@ export async function apply(ctx, config = {}) {
|
|
|
827
864
|
if (sawListen || backend !== BACKEND_NODE) return; // 只在 Node 分支、且从未就绪时给提示页
|
|
828
865
|
backendHint = renderBackendCrashHint({
|
|
829
866
|
entryPath, logPath: backendLogPath(extraEnv), code, signal: sig,
|
|
867
|
+
tailLines: crashTail.slice(),
|
|
830
868
|
});
|
|
831
869
|
};
|
|
832
870
|
myChild.on('exit', (code, sig) => {
|
|
@@ -834,6 +872,9 @@ export async function apply(ctx, config = {}) {
|
|
|
834
872
|
logger.warn(`touchstone: 后端退出 code=${code} sig=${sig}`);
|
|
835
873
|
crashed(code, sig);
|
|
836
874
|
});
|
|
875
|
+
// `exit` 可能先于 stdio 最后几个 chunk 交付(Node 文档:exit 后仍可能收到 data),
|
|
876
|
+
// `close` 才是「stdio 全部读空」。补渲染一次,让提示页里的最后几行是完整的。
|
|
877
|
+
myChild.on('close', (code, sig) => crashed(code, sig));
|
|
837
878
|
myChild.on('error', (err) => {
|
|
838
879
|
writeLog(`[touchstone] 后端拉起失败: ${err.message}`);
|
|
839
880
|
logger.warn(`touchstone: 后端拉起失败: ${err.message}`);
|
package/dsh-plugin/src/client.js
CHANGED
|
@@ -272,10 +272,117 @@ function panelEntry() {
|
|
|
272
272
|
return '/touchstone/app';
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
//
|
|
275
|
+
// ── 窗口装饰区让位(2026-10-17, 卡 987 方案 A) ─────────────────────────────────
|
|
276
|
+
// 血案: 面板是全窗口浮层(position:fixed; inset:0), 而桌面版窗口的顶带(Windows 的最小化/
|
|
277
|
+
// 最大化/关闭、macOS 的红绿灯)由桌面壳画在网页之上 —— 浮层顶部那行按钮正好落在它底下,
|
|
278
|
+
// 在 dsh desktop 里整块被盖住、点不到(现象与取证见
|
|
279
|
+
// doc_ai/debug_runtime/202610/20261017_0600_零项目首屏无可点建项目入口… .md)。
|
|
280
|
+
// 修法: 让整个浮层从窗口顶边**下移**「壳实际绘制的窗口 chrome 高度」—— 顶带完整露给壳
|
|
281
|
+
// (标题文字/拖拽区/窗口按钮都还在原位), 面板内容从它下面开始。等价于 dsh 自己主面板与
|
|
282
|
+
// 弹窗的处理口径(layout 发布 --dsh-frame-top-clearance / --dsh-frame-chrome-top),
|
|
283
|
+
// 而且**不需要 SPA 侧改任何代码**: iframe 拿到的视口自己就变矮了。
|
|
284
|
+
// 高度来源(优先级从高到低; 全部读不到 ⇒ 0 ⇒ 独立浏览器形态, 行为与今天完全一致):
|
|
285
|
+
// ① dsh 桌面壳发布在 <html> 上的约定量(见 @deepseek-ai/dsh-client-ui-layout 的
|
|
286
|
+
// README「窗口 chrome 顶带」; 普通浏览器文档不发布这些值):
|
|
287
|
+
// --dsh-frame-chrome-top 壳当前**实际绘制**的顶带高(全屏时归 0)
|
|
288
|
+
// --dsh-frame-top-clearance 顶带之下的固定下沉量(Windows = 标题栏高, macOS = 48px)
|
|
289
|
+
// **取两者较大**: 全屏时 chrome-top 归零, 但 dsh 的 Frame 仍按 titlebar 高度让位,
|
|
290
|
+
// 取 max 才能和主面板同口径、不会在全屏时又把窗口按钮盖回去。
|
|
291
|
+
// ② WCO 原生接口 navigator.windowControlsOverlay(Chromium/Electron; 旧桌面壳只开了
|
|
292
|
+
// overlay 但没发布 ① 的量时兜底): getTitlebarAreaRect() 的 y + height = 顶带高。
|
|
293
|
+
// ③ CSS env(titlebar-area-height) 探针(同一能力的老式访问面, 只支持 env 的壳兜底)。
|
|
294
|
+
// 仍读不到(普通浏览器/不支持窗口浮层的壳) ⇒ 0 ⇒ 现状(独立形态本来就没有窗口装饰)。
|
|
295
|
+
const CHROME_MAX_PX = 200; // 合法上限: 再大必然是脏值(标题栏不可能 200px), 一口拒绝
|
|
296
|
+
|
|
297
|
+
/** 读 <html> 上某个 CSS 自定义属性的像素值。空串/无法解析/越界 ⇒ null(0px 是合法值)。 */
|
|
298
|
+
function readChromePx(name) {
|
|
299
|
+
try {
|
|
300
|
+
if (typeof document === 'undefined' || !document.documentElement) return null;
|
|
301
|
+
const cs = window.getComputedStyle ? window.getComputedStyle(document.documentElement) : null;
|
|
302
|
+
if (!cs) return null;
|
|
303
|
+
const raw = cs.getPropertyValue(name);
|
|
304
|
+
if (!raw) return null;
|
|
305
|
+
const n = parseFloat(raw);
|
|
306
|
+
if (!Number.isFinite(n) || n < 0 || n > CHROME_MAX_PX) return null;
|
|
307
|
+
return n;
|
|
308
|
+
} catch { return null; } // 读样式异常绝不能拖垮面板本体
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** WCO 原生接口量顶带高(标题栏区域 rect 的 y + height); 不可见/不可用 ⇒ null。 */
|
|
312
|
+
function wcoChromePx() {
|
|
313
|
+
try {
|
|
314
|
+
const nav = window.navigator;
|
|
315
|
+
const wco = nav && nav.windowControlsOverlay;
|
|
316
|
+
if (!wco || !wco.visible) return null;
|
|
317
|
+
const rect = wco.getTitlebarAreaRect ? wco.getTitlebarAreaRect() : null;
|
|
318
|
+
if (!rect) return null;
|
|
319
|
+
const n = (rect.y || 0) + rect.height;
|
|
320
|
+
if (!Number.isFinite(n) || n <= 0 || n > CHROME_MAX_PX) return null;
|
|
321
|
+
return n;
|
|
322
|
+
} catch { return null; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** env(titlebar-area-height) 探针: 挂一个 0 宽、高度取 env 的隐形探针量出来; 不支持 ⇒ null。
|
|
326
|
+
* 探针始终会被摘掉(成功/失败都摘), 不往宿主 DOM 里留垃圾。 */
|
|
327
|
+
function envChromePx() {
|
|
328
|
+
try {
|
|
329
|
+
if (typeof document === 'undefined' || !document.body
|
|
330
|
+
|| typeof document.createElement !== 'function'
|
|
331
|
+
|| typeof document.body.appendChild !== 'function') return null;
|
|
332
|
+
const probe = document.createElement('div');
|
|
333
|
+
probe.style.cssText = 'position:fixed;left:0;top:0;width:0;'
|
|
334
|
+
+ 'height:env(titlebar-area-height,0px);visibility:hidden;pointer-events:none';
|
|
335
|
+
document.body.appendChild(probe);
|
|
336
|
+
let h = 0;
|
|
337
|
+
try {
|
|
338
|
+
h = probe.getBoundingClientRect ? probe.getBoundingClientRect().height : 0;
|
|
339
|
+
} finally {
|
|
340
|
+
if (typeof probe.remove === 'function') probe.remove();
|
|
341
|
+
else if (typeof document.body.removeChild === 'function') document.body.removeChild(probe);
|
|
342
|
+
}
|
|
343
|
+
if (!Number.isFinite(h) || h <= 0 || h > CHROME_MAX_PX) return null;
|
|
344
|
+
return h;
|
|
345
|
+
} catch { return null; }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 窗口 chrome 顶带高(px): 面板浮层从窗口顶边下移这么多。0 = 没有桌面窗口装饰。 */
|
|
349
|
+
function windowChromeTop() {
|
|
350
|
+
// ① dsh 桌面壳的约定量(两者取大, 口径见本节头注释)
|
|
351
|
+
const chrome = readChromePx('--dsh-frame-chrome-top');
|
|
352
|
+
const clearance = readChromePx('--dsh-frame-top-clearance');
|
|
353
|
+
if (chrome !== null || clearance !== null) {
|
|
354
|
+
return Math.max(chrome || 0, clearance || 0);
|
|
355
|
+
}
|
|
356
|
+
// ②③ 旧壳/其它壳兜底: WCO 原生接口 → env() 探针
|
|
357
|
+
return wcoChromePx() || envChromePx() || 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** chrome 顶带高的响应式读取: 渲染期现读(首帧就带真值, 不闪一帧顶格),
|
|
361
|
+
* 壳换档(resize/进出全屏/窗口几何变化)时自增重渲染。 */
|
|
362
|
+
function useWindowChromeTop() {
|
|
363
|
+
const [, setRevision] = React.useState(0);
|
|
364
|
+
React.useEffect(() => {
|
|
365
|
+
const bump = () => setRevision((n) => n + 1);
|
|
366
|
+
window.addEventListener('resize', bump);
|
|
367
|
+
document.addEventListener('fullscreenchange', bump);
|
|
368
|
+
// WCO 几何变化不是 DOM 事件(壳侧全屏/标题栏变高/贴边), 只能由原生接口通知
|
|
369
|
+
const wco = window.navigator && window.navigator.windowControlsOverlay;
|
|
370
|
+
if (wco && typeof wco.addEventListener === 'function') wco.addEventListener('geometrychange', bump);
|
|
371
|
+
return () => {
|
|
372
|
+
window.removeEventListener('resize', bump);
|
|
373
|
+
document.removeEventListener('fullscreenchange', bump);
|
|
374
|
+
if (wco && typeof wco.removeEventListener === 'function') wco.removeEventListener('geometrychange', bump);
|
|
375
|
+
};
|
|
376
|
+
}, []);
|
|
377
|
+
return windowChromeTop();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 全屏面板: 浮层从窗口 chrome 顶带之下开始(没有桌面壳时 = 0 = 吃满全屏) +
|
|
381
|
+
// 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
|
|
276
382
|
// iframe 同源, /touchstone/app 经薄壳反代到 server.py 的插件版 SPA
|
|
277
383
|
function Panel() {
|
|
278
384
|
const isOpen = useOpen();
|
|
385
|
+
const chromeTop = useWindowChromeTop(); // 窗口装饰顶带高: Windows 标题栏/macOS 红绿灯
|
|
279
386
|
// 保活(2026-10-07): 打开过就在 DOM 里常驻, 关闭只把容器 display:none —— iframe 一旦
|
|
280
387
|
// 从 DOM 摘掉, 内嵌 SPA 整篇文档就没了, 再打开是全新加载(路由/弹窗/滚动/未提交输入全丢)。
|
|
281
388
|
// mounted 由「打开过」与「当前打开」合成: 首次打开的那一次渲染就带上 iframe(不等 effect
|
|
@@ -382,7 +489,9 @@ function Panel() {
|
|
|
382
489
|
const visible = !zoneOk || shown; // 自动隐藏不可用时(zoneOk=false)保持常显
|
|
383
490
|
return h('div', {
|
|
384
491
|
style: {
|
|
385
|
-
|
|
492
|
+
// 顶边让出窗口装饰带(见 useWindowChromeTop): 桌面壳里 = 标题栏/红绿灯的高度,
|
|
493
|
+
// 普通浏览器里 = 0(与旧版 inset:0 完全一致)。其余三边照旧贴边。
|
|
494
|
+
position: 'fixed', top: chromeTop, left: 0, right: 0, bottom: 0, zIndex: 9999,
|
|
386
495
|
background: 'var(--dsw-alias-bg-base, #161617)',
|
|
387
496
|
display: isOpen ? 'block' : 'none', // 关闭 = 只隐藏: iframe 与内嵌 SPA 常驻(保活)
|
|
388
497
|
},
|