@gavinc-cn/touchstone-dsh 0.2.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.
Files changed (116) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +426 -0
  33. package/backend/lib/proc.js +361 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
package/lifecycle.py CHANGED
@@ -20,8 +20,14 @@ Python 后端是 dsh 宿主进程 spawn 的子进程。宿主退出/崩溃/被 `
20
20
  父还活着(例如 stdin 不是管道、或管道被无关方关闭)只记日志不自杀。
21
21
 
22
22
  2. **同库单实例** `SingleInstance`
23
- - `<db>.lock` 排它锁(`platcompat.lock_fd`)+ `<db>.pid` 记录
24
- `{pid, ppid, repo_dir, parent_watch, started_at}`。
23
+ - `<db>.lock` 上**两道锁**:`flock` 排它(`platcompat.lock_fd`)+ 同一文件上的
24
+ **SQLite 排它锁**(`locking_mode=EXCLUSIVE` + 一次写事务,持锁到进程结束);
25
+ 再写 `<db>.pid` 记录 `{pid, ppid, repo_dir, parent_watch, started_at}`。
26
+ - 为什么要两道(2026-10-11,Node 后端 M1):flock 只有 Python 侧有(Node 无
27
+ stdlib flock),Node 版改用 SQLite 自身的排它锁,两种锁**互不相干**
28
+ ⇒ 只靠 flock 挡不住 Node 版实例(实测:Node 持锁时 Python 的 flock 照样
29
+ 成功)。两边都取 SQLite 锁后,同语言与跨语言互斥都成立,且 `kill -9` 后
30
+ 由内核/文件锁自动释放,无需清理残留。
25
31
  - 锁被占时:读 pid 记录——若记录进程**由父死感知托管**(`parent_watch=true`)
26
32
  且其父已死 ⇒ 判定为**存量孤儿**(升级前的强杀残留),SIGTERM 后重试拿锁;
27
33
  否则视为真·另一实例,明确报错退出(防双写同库)。
@@ -39,6 +45,7 @@ Python 后端是 dsh 宿主进程 spawn 的子进程。宿主退出/崩溃/被 `
39
45
  import json
40
46
  import os
41
47
  import signal
48
+ import sqlite3
42
49
  import sys
43
50
  import threading
44
51
  import time
@@ -225,6 +232,8 @@ class SingleInstance:
225
232
  self.reason = ""
226
233
  self.reaped_pid = 0
227
234
  self._fd = None
235
+ # SQLite 排它锁的连接(第二道闸,M1);持锁期间常驻,release 时关闭
236
+ self._lock_conn = None
228
237
 
229
238
  # ---------- 记录读写 ----------
230
239
 
@@ -259,17 +268,21 @@ class SingleInstance:
259
268
 
260
269
  @staticmethod
261
270
  def _looks_like_server(pid):
262
- """粗校验 pid 是否是 touchstone 的 server.py(防 PID 复用误杀)。
271
+ """粗校验 pid 是否是 touchstone 的后端(防 PID 复用误杀)。
263
272
 
273
+ 两版后端的命令行都要认:Python 是 `server.py`,Node 是 `backend/server.js`
274
+ (2026-10-11 M1 起 Node 版也走同一套 `.pid` 记录与孤儿收口)。
264
275
  读不到命令行时返回 True(宁可不杀——见 acquire 的调用点:仅在
265
276
  「pid 记录声明 parent_watch 且其父已死」时才走到这里)。
266
277
  """
267
278
  try:
268
279
  if sys.platform.startswith("linux"):
269
280
  with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
270
- return b"server.py" in f.read()
281
+ cmdline = f.read()
282
+ return b"server.py" in cmdline or b"server.js" in cmdline
271
283
  import psutil
272
- return any("server.py" in str(a) for a in psutil.Process(int(pid)).cmdline())
284
+ argv = [str(a) for a in psutil.Process(int(pid)).cmdline()]
285
+ return any("server.py" in a or "server.js" in a for a in argv)
273
286
  except Exception: # noqa: BLE001
274
287
  return True
275
288
 
@@ -308,12 +321,93 @@ class SingleInstance:
308
321
 
309
322
  # ---------- 加锁 ----------
310
323
 
324
+ def _acquire_sqlite_lock(self):
325
+ """第二道闸:同一锁文件上的 SQLite 排它锁(M1,2026-10-11)。
326
+
327
+ Node 版没有 stdlib `flock`,改用 SQLite 自身的排它锁:在 `<db>.lock` 上
328
+ `PRAGMA locking_mode=EXCLUSIVE` + 一次写事务,锁持有到进程/连接结束
329
+ (`kill -9` 由内核释放,无残留文件要清理)。两种锁机制**互不相干**,所以
330
+ 两边都要取这一道,跨语言互斥才成立(实测:Node 持锁时 Python 的 flock
331
+ 照样能拿到)。
332
+
333
+ 返回 `(状态, 文案)`:
334
+ - `("ok", "")` 拿到(连接挂在 `self._lock_conn`)
335
+ - `("busy", "")` 被占用(调用方报「另一实例」并退出)
336
+ - `("unusable", 文案)` 锁文件本身不可用(损坏/是目录/打不开)——
337
+ **不再降级放行**(2026-10-11 评审 P1):降级等于互斥无声消失,
338
+ 实测「先起 Python 再起 Node」双方都能就绪 ⇒ 同库双写。
339
+ """
340
+ try:
341
+ conn = sqlite3.connect(self.lock_path, timeout=0)
342
+ except sqlite3.Error as e:
343
+ return "unusable", self._unusable_reason(e)
344
+ try:
345
+ conn.execute("PRAGMA busy_timeout=0")
346
+ conn.execute("PRAGMA locking_mode=EXCLUSIVE")
347
+ # 必须真写一次:EXCLUSIVE 模式在首个写事务时取锁并一直持有
348
+ conn.execute("CREATE TABLE IF NOT EXISTS lock_holder ("
349
+ " id INTEGER PRIMARY KEY CHECK (id = 1),"
350
+ " pid INTEGER NOT NULL, started_at INTEGER NOT NULL)")
351
+ conn.execute("INSERT OR REPLACE INTO lock_holder(id, pid, started_at)"
352
+ " VALUES(1, ?, ?)", (os.getpid(), int(time.time())))
353
+ conn.commit()
354
+ except sqlite3.Error as e:
355
+ try:
356
+ conn.close()
357
+ except sqlite3.Error:
358
+ pass
359
+ if "locked" in str(e) or "busy" in str(e):
360
+ return "busy", ""
361
+ return "unusable", self._unusable_reason(e)
362
+ self._lock_conn = conn
363
+ return "ok", ""
364
+
365
+ def _unusable_reason(self, error):
366
+ """锁文件不可用的报错文案(与 Node `lock.js:unusableLockReason` 同义)。"""
367
+ return (f"单实例锁文件不可用:{self.lock_path}(库 {self.db_file})。原因:{error}。"
368
+ f"该文件应为 SQLite 库或 0 字节空文件;内容损坏/被换成目录时无法建立互斥,"
369
+ f"继续启动会与另一版后端同库双写(任务重复执行、队列行被抢)。"
370
+ f"修复:确认没有实例在跑之后把它移走再启动"
371
+ f"(mv {self.lock_path} {self.lock_path}.broken);"
372
+ f"或显式 --allow-shared-db 自担双写风险。")
373
+
374
+ def _release_sqlite_lock(self):
375
+ """释放 SQLite 排它锁(幂等)。"""
376
+ conn, self._lock_conn = self._lock_conn, None
377
+ if conn is None:
378
+ return
379
+ try:
380
+ conn.close()
381
+ except sqlite3.Error:
382
+ pass
383
+
384
+ def _try_take_locks(self):
385
+ """两道锁的状态:`("ok"|"busy"|"unusable", 文案)`。
386
+
387
+ flock 拿到但 SQLite 被占/不可用时**回退 flock**:留着不放会挡住下一个 Python
388
+ 实例去走「孤儿收口 → 重试」的路径(它只看到 flock 被占),把可恢复的孤儿变成死锁。
389
+ """
390
+ if not platcompat.lock_fd(self._fd, blocking=False):
391
+ return "busy", ""
392
+ state, detail = self._acquire_sqlite_lock()
393
+ if state == "ok":
394
+ return "ok", ""
395
+ try:
396
+ platcompat.unlock_fd(self._fd)
397
+ except OSError:
398
+ pass
399
+ return state, detail
400
+
311
401
  def acquire(self):
312
- """拿同库单实例锁。True=拿到(已写 pid 记录);False=被真·另一实例占用。
402
+ """拿同库单实例锁。True=拿到(已写 pid 记录);False=不能独占(`reason` 说明原因)。
313
403
 
314
404
  非阻塞试锁 + 存量孤儿收口:拿到即写记录返回;拿不到先读 pid 记录——
315
405
  若记录进程由父死感知托管且其父已死,判为存量孤儿,SIGTERM 后在
316
406
  `stale_wait` 内重试;否则立即返回 False 并给出可执行的报错文案。
407
+
408
+ **不再有「降级为无锁运行」分支**(2026-10-11 评审 P1):锁文件打不开/损坏时
409
+ 返回 False(调用方 exit 3)并给修复步骤——静默降级会让互斥无声消失、同库双写,
410
+ 那比「启动被拒」严重得多。
317
411
  """
318
412
  os.makedirs(os.path.dirname(self.lock_path), exist_ok=True)
319
413
  if self.allow_shared:
@@ -322,15 +416,18 @@ class SingleInstance:
322
416
  try:
323
417
  fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o644)
324
418
  except OSError as e:
325
- self.log(f"[lifecycle] 锁文件打开失败(降级为无锁运行): {e}")
326
- return True
419
+ self.reason = self._unusable_reason(e)
420
+ return False
327
421
  self._fd = fd
328
- deadline = time.time() + max(self.stale_wait, 0.5) + 1.0
329
422
  reaped = False
330
423
  while True:
331
- if platcompat.lock_fd(fd, blocking=False):
424
+ state, detail = self._try_take_locks()
425
+ if state == "ok":
332
426
  self._write_record(os.getpid())
333
427
  return True
428
+ if state == "unusable":
429
+ self.reason = detail
430
+ return False
334
431
  rec = self._read_record()
335
432
  if rec and not reaped and self._reap_stale(rec):
336
433
  reaped = True # 孤儿已收口:其锁随进程退出释放,再试
@@ -343,9 +440,14 @@ class SingleInstance:
343
440
  f"(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),"
344
441
  f"或显式 --allow-shared-db 自担双写风险。")
345
442
  else:
346
- self.reason = (f"锁文件被占用且无归属记录:{self.lock_path}"
347
- f"(库 {self.db_file})。若确认无实例在跑,"
348
- f"删除该锁文件后重试。")
443
+ # 无归属记录时**不要**教用户删锁文件:删掉后新实例会在新 inode 上取锁,
444
+ # 与仍在运行的占用者双写(flock 与 SQLite 锁同理)。给安全的两个出口。
445
+ self.reason = (
446
+ f"锁文件被占用且无归属记录:{self.lock_path}"
447
+ f"(库 {self.db_file})。占用者可能是另一版后端的异常窗口"
448
+ f"(Node 版取锁后、写 .pid 前被杀)或残留的第三方进程;"
449
+ f"如需并行请用独立库(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),"
450
+ f"或显式 --allow-shared-db 自担双写风险。")
349
451
  return False
350
452
  # 不可达:while True 内所有分支都 return(保静态检查可读性)
351
453
 
@@ -354,6 +456,7 @@ class SingleInstance:
354
456
  rec = self._read_record()
355
457
  if rec and int(rec.get("pid") or 0) == os.getpid():
356
458
  self._remove_record()
459
+ self._release_sqlite_lock()
357
460
  if self._fd is not None:
358
461
  platcompat.unlock_fd(self._fd)
359
462
  try:
package/loadcase.py CHANGED
File without changes
package/loadgen.py CHANGED
File without changes
package/localbus.py CHANGED
File without changes
package/migrate_p7b.py CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavinc-cn/touchstone-dsh",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI-agent-driven automated testing platform for DeepSeek Harness (dsh): Markdown case library, bug reports, per-project serial queue, development board and built-in load testing.",
5
5
  "license": "MIT",
6
6
  "author": "Touchstone contributors",
@@ -27,6 +27,10 @@
27
27
  "files": [
28
28
  "*.py",
29
29
  "requirements.txt",
30
+ "backend/server.js",
31
+ "backend/lib",
32
+ "backend/routes",
33
+ "backend/schema",
30
34
  "builtin_prompts",
31
35
  "extensions",
32
36
  "dsh-plugin/cordis.patch.yml",
@@ -55,6 +59,7 @@
55
59
  },
56
60
  "scripts": {
57
61
  "build:client": "node dsh-plugin/scripts/build.mjs",
62
+ "test:backend": "node --test \"backend/test/**/*.test.mjs\"",
58
63
  "prepack": "node dsh-plugin/scripts/prepack-check.mjs"
59
64
  },
60
65
  "repository": {
package/platcompat.py CHANGED
File without changes
package/prompts.py CHANGED
File without changes
package/rag.py CHANGED
File without changes
package/requirements.txt CHANGED
File without changes
package/server.py CHANGED
@@ -852,6 +852,20 @@ def fs_roots():
852
852
  FILE_PREVIEW_MAX = 256 * 1024
853
853
  FILE_RAW_MAX = 32 * 1024 * 1024
854
854
 
855
+ # `file?inline=1` 的内联白名单(2026-10-16):**只放行浏览器可直接渲染的位图**。
856
+ # 与 raw=1「一律 text/plain 或附件下载」的红线配套——按扩展名把 agent 在工作区写的内容
857
+ # 内联下发,等于让同源页面按该类型解释它;`html`/`svg`/`js` 这类可执行/可带脚本的类型
858
+ # 一律不给,只给位图(`<img>` 里不会执行任何东西)。扩展名口径与 `os.path.splitext` 一致,
859
+ # 大小写不敏感(见 `_send_project_file_inline`)。
860
+ PROJECT_INLINE_IMAGE_TYPES = {
861
+ "png": "image/png",
862
+ "jpg": "image/jpeg",
863
+ "jpeg": "image/jpeg",
864
+ "gif": "image/gif",
865
+ "webp": "image/webp",
866
+ "bmp": "image/bmp",
867
+ }
868
+
855
869
 
856
870
  def _within_root(path, root):
857
871
  """path 是否等于 root 或位于 root 之内(调用前双方均须 realpath 规范化)。"""
@@ -1068,6 +1082,10 @@ class Handler(BaseHTTPRequestHandler):
1068
1082
  if path == "/api/me/feishu/doctor":
1069
1083
  self._api_me_feishu_doctor(body)
1070
1084
  return
1085
+ if path == "/api/me/feishu/reconnect":
1086
+ # 设置页「重连入站」按钮(M11.1):进程内停旧长连接 → 用当前库里凭据起新
1087
+ self._api_me_feishu_reconnect()
1088
+ return
1071
1089
  if path == "/api/me/feishu-hook/apply":
1072
1090
  # 项目推送绑定批量应用(设置页「同时应用到我的全部项目」,2026-10-10)
1073
1091
  self._api_me_feishu_hook_apply(body)
@@ -1220,7 +1238,7 @@ class Handler(BaseHTTPRequestHandler):
1220
1238
  return
1221
1239
  m = re.match(r"^/api/tasks/(\d+)/session/chat/stop$", path)
1222
1240
  if m:
1223
- self._api_session_chat_stop(int(m.group(1)))
1241
+ self._api_session_chat_stop(int(m.group(1)), body)
1224
1242
  return
1225
1243
  self._respond(404, b'{"error":"not found"}', "application/json; charset=utf-8")
1226
1244
 
@@ -1805,6 +1823,29 @@ class Handler(BaseHTTPRequestHandler):
1805
1823
  self._respond(200, json.dumps(out, ensure_ascii=False).encode("utf-8"),
1806
1824
  "application/json; charset=utf-8")
1807
1825
 
1826
+ def _api_me_feishu_reconnect(self):
1827
+ """重连本人入站长连接(M11.1 设置页面板「重连入站」按钮的写口)。
1828
+
1829
+ 停旧连接 → 用**当前库里**的凭据起新(`feishu.restart_inbound_for`,M11 已落地)。
1830
+ 响应 `{"ok": true, "started": bool, "inbound": {...}}`,键序与 `json.dumps` 分隔符是
1831
+ 对外契约(逐字节判据:`tests/test_feishu_reconnect_api.py` + 对拍
1832
+ `cases-http/m11_feishu_reconnect.json`):
1833
+
1834
+ - `ok` 恒 true:动作已执行,**不用 HTTP 错误码**表达连接成败(与 slash/provision
1835
+ 族「200 + ok/error 字段」同一口径;真正的故障原因看 `started`/`inbound`/日志);
1836
+ - `started` = 是否起了**新**连接(未配置凭据、旧线程没停下来 ⇒ false,语义照抄
1837
+ `restart_inbound_for` 的返回值)——面板据此区分「没配置」「旧连接没停下」「起来了」;
1838
+ - `inbound` = 与 `GET /api/me/feishu-cfg` 的 `inbound` **同形两键**
1839
+ (`configured`/`thread`;`inbound_status` 的形状契约见其 docstring)。
1840
+
1841
+ 不读体(`do_POST` 已在分派前统一读掉 body;本函数不碰它)。"""
1842
+ user = self._current_user()
1843
+ started = feishu.restart_inbound_for(user["id"])
1844
+ self._respond(200, json.dumps(
1845
+ {"ok": True, "started": bool(started),
1846
+ "inbound": feishu.inbound_status(user["id"])},
1847
+ ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
1848
+
1808
1849
  def _api_me_feishu_outbox(self):
1809
1850
  """当前用户的最近投递记录(target 打码——URL 本身含 token 即凭据)。"""
1810
1851
  user = self._current_user()
@@ -2370,10 +2411,14 @@ class Handler(BaseHTTPRequestHandler):
2370
2411
  """读取项目范围内的文件内容供会话页预览(只读,多用户隔离)。
2371
2412
 
2372
2413
  query: path=相对/绝对路径(相对按 项目目录 → 工作目录 解析);
2373
- raw=1 直接返回原文(前端「新标签打开」用)。
2414
+ raw=1 直接返回原文(前端「新标签打开」用);
2415
+ inline=1 把项目范围内的**位图**按原图内联下发(会话页渲染 agent 写的
2416
+ 截图/示意图用,见 `_send_project_file_inline`;两参数都给时 inline 优先)。
2374
2417
  安全:路径须落在本项目「项目目录/工作目录」之内(realpath 包含判定,
2375
2418
  防目录穿越与越权读他人文件);raw 一律 text/plain + nosniff——这些文件是
2376
- agent 在工作区写的内容,按扩展名猜类型内联下发会让同源页面执行其中脚本。
2419
+ agent 在工作区写的内容,按扩展名猜类型内联下发会让同源页面执行其中脚本;
2420
+ inline=1 是这条红线上的**收窄例外**:只放行 `PROJECT_INLINE_IMAGE_TYPES`
2421
+ 里的位图扩展名(附件目录里的 fid 文件同样适用,不必先上传成卡片附件)。
2377
2422
  """
2378
2423
  row = self._owned_project(project_id)
2379
2424
  if row is None:
@@ -2392,6 +2437,9 @@ class Handler(BaseHTTPRequestHandler):
2392
2437
  self._respond(code, json.dumps({"error": msg}, ensure_ascii=False).encode("utf-8"),
2393
2438
  "application/json; charset=utf-8")
2394
2439
  return
2440
+ if parsed.get("inline", ["0"])[0] in ("1", "true", "yes"):
2441
+ self._send_project_file_inline(path)
2442
+ return
2395
2443
  if parsed.get("raw", ["0"])[0] in ("1", "true", "yes"):
2396
2444
  self._send_project_file_raw(path)
2397
2445
  return
@@ -2459,6 +2507,50 @@ class Handler(BaseHTTPRequestHandler):
2459
2507
  except (BrokenPipeError, ConnectionResetError, OSError):
2460
2508
  pass # 客户端提前断开属常态,静默处理
2461
2509
 
2510
+ def _send_project_file_inline(self, path):
2511
+ """`inline=1`:项目范围内的位图按原图内联下发(会话页渲染 agent 写的截图)。
2512
+
2513
+ 与 `_send_project_file_raw` 只差两处:Content-Type 按扩展名给真图片类型、
2514
+ 不带 `Content-Disposition: attachment`(浏览器才肯在 `<img>` 里渲染)。
2515
+ 仍带 nosniff(不许嗅探成可执行类型)与 no-store(工作区文件随时被 agent 改写,
2516
+ 缓存住会看到旧图)。扩展名不在 `PROJECT_INLINE_IMAGE_TYPES` 里 ⇒ 400(白名单,
2517
+ 不是「不支持就按 raw 下发」——那正是 raw 红线要防的事);超 FILE_RAW_MAX ⇒ 413。
2518
+ """
2519
+ ext = os.path.splitext(path)[1].lstrip(".").lower()
2520
+ ctype = PROJECT_INLINE_IMAGE_TYPES.get(ext)
2521
+ if ctype is None:
2522
+ self._respond(400, json.dumps({"error": "该类型不支持内联显示"},
2523
+ ensure_ascii=False).encode("utf-8"),
2524
+ "application/json; charset=utf-8")
2525
+ return
2526
+ try:
2527
+ size = os.path.getsize(path)
2528
+ except OSError:
2529
+ self._respond(404, json.dumps({"error": "文件不存在或不可读"},
2530
+ ensure_ascii=False).encode("utf-8"),
2531
+ "application/json; charset=utf-8")
2532
+ return
2533
+ if size > FILE_RAW_MAX:
2534
+ self._respond(413, json.dumps({"error": "文件过大,无法内联显示"},
2535
+ ensure_ascii=False).encode("utf-8"),
2536
+ "application/json; charset=utf-8")
2537
+ return
2538
+ self.send_response(200)
2539
+ self.send_header("Content-Type", ctype)
2540
+ self.send_header("Content-Length", str(size))
2541
+ self.send_header("Cache-Control", "no-store")
2542
+ self.send_header("X-Content-Type-Options", "nosniff")
2543
+ self.end_headers()
2544
+ try:
2545
+ with open(path, "rb") as f:
2546
+ while True:
2547
+ chunk = f.read(64 * 1024)
2548
+ if not chunk:
2549
+ break
2550
+ self.wfile.write(chunk)
2551
+ except (BrokenPipeError, ConnectionResetError, OSError):
2552
+ pass # 客户端提前断开属常态,静默处理
2553
+
2462
2554
  def _api_archive_project(self, project_id, archived):
2463
2555
  """归档/恢复项目:仅切换 archived 标记,不删数据、不影响运行中任务。
2464
2556
 
@@ -3093,6 +3185,11 @@ class Handler(BaseHTTPRequestHandler):
3093
3185
  if row is None:
3094
3186
  return
3095
3187
  if action == "stop":
3188
+ # mode=cancel_queue(2026-10-17):会话窗「取消排队」——纯平台侧取消排队消息、
3189
+ # 不碰轮次;缺省/其它值 ⇒ 原样停止(旧前端不带体,响应体一字不变)。
3190
+ if (body.get("mode") or "") == "cancel_queue":
3191
+ self._api_cancel_queue(body, card_id=card_id)
3192
+ return
3096
3193
  hit = board.stop_card(card_id)
3097
3194
  self._respond(200, json.dumps({"ok": True, "stopped": hit}).encode("utf-8"),
3098
3195
  "application/json; charset=utf-8")
@@ -4991,16 +5088,58 @@ class Handler(BaseHTTPRequestHandler):
4991
5088
  "prompt_id": rec.get("prompt_id") or ""},
4992
5089
  ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
4993
5090
 
4994
- def _api_session_chat_stop(self, task_id):
4995
- """停止进行中的对话进程(同时取消该会话排队中的消息)。"""
5091
+ def _api_session_chat_stop(self, task_id, body=None):
5092
+ """停止进行中的对话进程(同时取消该会话排队中的消息)。
5093
+
5094
+ `mode=cancel_queue`(2026-10-17)⇒ 只取消排队消息、**不碰轮次**(会话窗「取消排队」
5095
+ 按钮);缺省/其它值 ⇒ 原样停止(旧前端不带体,行为与响应体一字不变)。
5096
+ """
4996
5097
  task, _project, _family = self._session_context(task_id)
4997
5098
  if task is None:
4998
5099
  self._respond(404, b'{"error":"task not found"}',
4999
5100
  "application/json; charset=utf-8")
5000
5101
  return
5001
- chat.stop(task["session_id"] or "")
5102
+ sid = task["session_id"] or ""
5103
+ if ((body or {}).get("mode") or "") == "cancel_queue":
5104
+ self._api_cancel_queue(body or {}, sid=sid, task_id=task_id)
5105
+ return
5106
+ chat.stop(sid)
5002
5107
  self._respond(200, b'{"ok":true}', "application/json; charset=utf-8")
5003
5108
 
5109
+ def _api_cancel_queue(self, body, sid="", card_id=None, task_id=None, project_id=None):
5110
+ """`mode=cancel_queue` 的共用实现(任务/看板两路):**只取消平台排队消息,不碰轮次**。
5111
+
5112
+ 这是会话窗「取消排队」按钮的服务端落点,与「停止」是两件事(用户 2026-10-16 裁定
5113
+ U4:停止只打断当前轮;取消排队是另一个显式动作)。**纯平台侧**:删的是 chat_msgs
5114
+ 排队行与 m: 等待项行,不经过驱动 ⇒ 池外会话(dsh GUI 直跑/看管未接回)同样有效。
5115
+
5116
+ 响应回带被取消消息的 {id,text}(**全文**,见 chat.cancel_queued_detail):前端要把
5117
+ 原文放回输入框,而下发给前端的排队行文本被截到 120 字。cancelled=0 不是错误(消息
5118
+ 可能已被 worker 抢走执行,或已被别的路径取消)。
5119
+
5120
+ body.msg_id 非空 ⇒ 只取消该条(排队行的行内「取消」按钮);归属判定照 _inject_msg
5121
+ 的双/三重口径,不命中同一个 404 文案(不细分,避免暴露他人消息是否存在)。
5122
+ 与 Node 半 routes/sessions.js:cancelQueueAction 同形。
5123
+ """
5124
+ msg_id = str(body.get("msg_id") or "")
5125
+ if msg_id:
5126
+ info = chat.msg_info(msg_id)
5127
+ if (info is None
5128
+ or (sid and info.get("sid") != sid)
5129
+ or (project_id is not None and info.get("project_id") != project_id)
5130
+ or (card_id is not None and info.get("card_id") != card_id)
5131
+ or (task_id is not None and info.get("task_id") != task_id)):
5132
+ self._respond(404, json.dumps({"error": "消息不存在或不在该会话下"},
5133
+ ensure_ascii=False).encode("utf-8"),
5134
+ "application/json; charset=utf-8")
5135
+ return
5136
+ out = chat.cancel_queued_detail(sid=sid or None, card_id=card_id,
5137
+ msg_id=msg_id or None)
5138
+ self._respond(200, json.dumps({"ok": True, "cancelled": out["cancelled"],
5139
+ "msgs": out["msgs"]},
5140
+ ensure_ascii=False).encode("utf-8"),
5141
+ "application/json; charset=utf-8")
5142
+
5004
5143
  # ---------- 监控 state / stream ----------
5005
5144
 
5006
5145
  def _resolve_project(self, parsed):
@@ -5392,10 +5531,14 @@ def _build_arg_parser():
5392
5531
  def _seed_hint(seeded, initial_pw=""):
5393
5532
  """启动横幅的默认账号提示:仅本次实际种子时返回文案,日常启动为空串。
5394
5533
 
5395
- 2026-10-02 起默认口令不再是固定值:未设 TS_ADMIN_PASSWORD 时随机生成一次性
5396
- 初始口令(initial_pw,由 db.take_seed_password() 取走),横幅打印这一次即清,
5397
- 且该账号带 must_change_pw=1,首次登录必须先改密;设了 TS_ADMIN_PASSWORD 时
5398
- 只提示变量名、不回显口令值。
5534
+ 三档与 `db.seed_admin` 的口令来源一一对应:
5535
+ - initial_pw 非空(随机一次性口令):打印口令本身,首登须改密;
5536
+ - `TS_ADMIN_PASSWORD` 非空:只提示变量名、不回显口令值;
5537
+ - 都没有(`TS_ADMIN_PASSWORDLESS=1`,插件形态):**空口令**——2026-10-16 起单列,
5538
+ 此前落进第二档文案(凭空指向一个没设的变量)或「已在上一次打印」文案(该分支
5539
+ 结构上不可达于那种情形:seeded=False 时直接返回空串,seeded=True 且 initial_pw
5540
+ 为空只可能是空口令种子)。两版后端的这一档文案逐字节相同,见
5541
+ `backend/lib/seed_hint.js`。
5399
5542
  """
5400
5543
  if not seeded:
5401
5544
  return ""
@@ -5404,7 +5547,8 @@ def _seed_hint(seeded, initial_pw=""):
5404
5547
  ",首次登录须修改;仅本次显示,请立即记录)")
5405
5548
  if os.environ.get("TS_ADMIN_PASSWORD"):
5406
5549
  return " (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)"
5407
- return " (首次启动已创建默认账号 admin,其一次性口令已在上一次启动时打印,本次不再显示)"
5550
+ return (" (首次启动已创建默认账号 admin,空口令(插件形态免登)"
5551
+ ";如需口令登录请在设置页设置密码)")
5408
5552
 
5409
5553
 
5410
5554
  def main():
package/touchstone.py CHANGED
File without changes
package/waitq.py CHANGED
File without changes