codebee 0.1.101 → 0.1.103

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 (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ """番茄建书分类弹层实地探测:复现点击序列 → dump 弹层真实结构。只读不提交。"""
3
+ import sys
4
+ import time
5
+ import json
6
+
7
+ sys.path.insert(0, r"E:\GoOut\MultiAgentOrchestration")
8
+ from app.core.publish import manager # noqa: E402
9
+
10
+ b, page = manager._open_page("fanqie")
11
+ # 硬刷新拿干净表单(同 URL 导航 SPA 不重置,上次建书残留会干扰)
12
+ page.navigate("https://fanqienovel.com/main/writer/create", timeout=30)
13
+ page.call("() => location.reload()")
14
+ time.sleep(5)
15
+
16
+ CLICK_JS = """(t) => {
17
+ const els = [...document.querySelectorAll('span,div,button,label')]
18
+ .filter(e => e.offsetParent && e.textContent.trim() === t);
19
+ if (!els.length) return {ok: false, why: 'no ' + t};
20
+ els[els.length - 1].click();
21
+ return {ok: true, n: els.length};
22
+ }"""
23
+
24
+ for text in ["连载模式", "男频"]:
25
+ r = page.call(CLICK_JS, [text])
26
+ print(text, "->", r)
27
+ time.sleep(1.5)
28
+
29
+ time.sleep(1)
30
+ r = page.call("""() => {
31
+ const els = [...document.querySelectorAll('span,div,button,label')]
32
+ .filter(e => e.offsetParent && e.textContent.includes('请选择作品标签'));
33
+ if (!els.length) return {ok: false, why: 'no tag entry'};
34
+ els[els.length - 1].click();
35
+ return {ok: true, n: els.length};
36
+ }""")
37
+ print("请选择作品标签 ->", r)
38
+ time.sleep(3)
39
+
40
+ DUMP_JS = """() => {
41
+ const cm = document.querySelector('.category-modal');
42
+ const out = {category_modal_exists: !!cm, cm_class: cm ? String(cm.className) : ''};
43
+ const dialogs = [...document.querySelectorAll(
44
+ '[class*=modal],[class*=dialog],[class*=popover],[class*=picker],[class*=drawer],[class*=popup]')]
45
+ .filter(e => e.offsetParent).slice(0, 8);
46
+ out.dialogs = dialogs.map(d => ({
47
+ cls: String(d.className).slice(0, 100),
48
+ text: (d.innerText || '').slice(0, 500).split('\\n').join('|')
49
+ }));
50
+ out.has_dushi = document.body.innerText.indexOf('都市脑洞') >= 0;
51
+ out.body_snip = document.body.innerText.slice(0, 600).split('\\n').join('|');
52
+ return out;
53
+ }"""
54
+ info = page.call(DUMP_JS)
55
+ print(json.dumps(info, ensure_ascii=False, indent=1))
@@ -0,0 +1,71 @@
1
+ # -*- coding: utf-8 -*-
2
+ """逐点试探「请选择作品标签」:不同落点/事件序列,找出能打开分类弹层的精确触发方式。只读。"""
3
+ import sys
4
+ import time
5
+ import json
6
+
7
+ sys.path.insert(0, r"E:\GoOut\MultiAgentOrchestration")
8
+ from app.core.publish import manager # noqa: E402
9
+
10
+ b, page = manager._open_page("fanqie")
11
+ page.navigate("https://fanqienovel.com/main/writer/create", timeout=30)
12
+ page.call("() => location.reload()")
13
+ time.sleep(5)
14
+
15
+ # 前置:先把签约模式/目标读者点上(复现产品完整前置链)
16
+ for text in ["连载模式", "男频"]:
17
+ r = page.real_click_text(text, "label.arco-radio,span.arco-radio,[class*=serial-radio]",
18
+ y_max=430)
19
+ print("前置点击", text, "->", r)
20
+ time.sleep(1.0)
21
+
22
+ MODAL_JS = "() => !!document.querySelector('.category-modal')"
23
+
24
+ # 找到 select-row 及其内部结构
25
+ r = page.call("""() => {
26
+ const row = [...document.querySelectorAll('.select-row')]
27
+ .find(e => (e.innerText || '').includes('请选择作品标签'));
28
+ if (!row) return {ok: false};
29
+ const inner = [...row.querySelectorAll('*')].slice(0, 8).map(e => {
30
+ const rc = e.getBoundingClientRect();
31
+ return {tag: e.tagName, cls: String(e.className).slice(0, 50),
32
+ x: Math.round(rc.x + rc.width / 2), y: Math.round(rc.y + rc.height / 2)};
33
+ });
34
+ const rc = row.getBoundingClientRect();
35
+ return {ok: true, row: {x: Math.round(rc.x + rc.width / 2), y: Math.round(rc.y + rc.height / 2)},
36
+ inner: inner};
37
+ }""")
38
+ print("select-row 结构:", json.dumps(r, ensure_ascii=False))
39
+ if not r.get("ok"):
40
+ sys.exit("select-row 没找到")
41
+
42
+ targets = [r["row"]] + r["inner"]
43
+ for t in targets:
44
+ x, y = t["x"], t["y"]
45
+ page.send("Input.dispatchMouseEvent",
46
+ {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1},
47
+ timeout=8.0)
48
+ page.send("Input.dispatchMouseEvent",
49
+ {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1},
50
+ timeout=8.0)
51
+ time.sleep(1.8)
52
+ opened = page.call(MODAL_JS)
53
+ print("点 %s(%s) @(%d,%d) -> 弹层: %s" % (t.get("tag"), t.get("cls", "")[:30], x, y, opened))
54
+ if opened:
55
+ print(">>> 弹层开了!落点:", json.dumps(t, ensure_ascii=False))
56
+ # dump 弹层内主分类区
57
+ info = page.call("""() => {
58
+ const cm = document.querySelector('.category-modal');
59
+ return {cls: String(cm.className).slice(0, 80),
60
+ txt: (cm.innerText || '').slice(0, 300).split('\\n').join('|')};
61
+ }""")
62
+ print(json.dumps(info, ensure_ascii=False, indent=1))
63
+ break
64
+ # 收起可能的意外浮层,回干净状态再试下一个点
65
+ page.send("Input.dispatchMouseEvent",
66
+ {"type": "mousePressed", "x": 200, "y": 700, "button": "left", "clickCount": 1},
67
+ timeout=8.0)
68
+ page.send("Input.dispatchMouseEvent",
69
+ {"type": "mouseReleased", "x": 200, "y": 700, "button": "left", "clickCount": 1},
70
+ timeout=8.0)
71
+ time.sleep(0.8)
@@ -0,0 +1,34 @@
1
+ # -*- coding: utf-8 -*-
2
+ """实地补点「立即创建」并全程盯提交反应(弹窗/跳转)。真实提交,已获用户批准。"""
3
+ import sys
4
+ import time
5
+ import json
6
+
7
+ sys.path.insert(0, r"E:\GoOut\MultiAgentOrchestration")
8
+ from app.core.publish import manager # noqa: E402
9
+
10
+ b, page = manager._open_page("fanqie")
11
+ print("url before:", page.url())
12
+
13
+ r = page.real_click_text("立即创建", "button", contains=False)
14
+ print("点「立即创建」->", r)
15
+
16
+ for i in range(12):
17
+ time.sleep(2.5)
18
+ url = str(page.url() or "")
19
+ st = page.call("""() => {
20
+ const dlg = [...document.querySelectorAll('.arco-modal,[class*=dialog],[class*=modal]')]
21
+ .filter(e => e.offsetParent && (e.innerText || '').trim())
22
+ .slice(0, 3)
23
+ .map(e => ({cls: String(e.className).slice(0, 50),
24
+ txt: (e.innerText || '').slice(0, 200).split('\\n').join('|')}));
25
+ return {dlg: dlg,
26
+ toast: ([...document.querySelectorAll('[class*=message],[class*=toast]')]
27
+ .filter(e => e.offsetParent).map(e => (e.innerText || '').slice(0, 80)) )};
28
+ }""")
29
+ print("[%02d] url=%s" % (i, url[:90]))
30
+ if st.get("dlg") or st.get("toast"):
31
+ print(" 弹窗/提示:", json.dumps(st, ensure_ascii=False)[:400])
32
+ if "book-info" in url or "chapter" in url:
33
+ print(">>> 建书成功跳转!")
34
+ break
@@ -0,0 +1,62 @@
1
+ # -*- coding: utf-8 -*-
2
+ """建书全程盯梢:触发建书线程的同时高频 dump 页面弹窗/toast/URL,抓提交瞬间反应。"""
3
+ import sys
4
+ import time
5
+ import json
6
+ import threading
7
+
8
+ sys.path.insert(0, r"E:\GoOut\MultiAgentOrchestration")
9
+
10
+ BODY = r"E:\GoOut\MultiAgentOrchestration\app\output\_createbook_body.json"
11
+
12
+ def trigger():
13
+ import http.client
14
+ body = open(BODY, "rb").read()
15
+ conn = http.client.HTTPConnection("127.0.0.1", 8765, timeout=30)
16
+ conn.request("POST", "/api/control",
17
+ json.dumps({"action": "acquire", "force": True}).encode(),
18
+ {"Content-Type": "application/json", "X-CodeBee-Client": "cli-maint-0930"})
19
+ conn.getresponse().read()
20
+ conn.request("POST", "/api/publish/task/t-20260924-215431-0715/create-book",
21
+ body, {"Content-Type": "application/json",
22
+ "X-CodeBee-Client": "cli-maint-0930"})
23
+ print("trigger:", conn.getresponse().read()[:120])
24
+ conn.request("POST", "/api/control",
25
+ json.dumps({"action": "release"}).encode(),
26
+ {"Content-Type": "application/json", "X-CodeBee-Client": "cli-maint-0930"})
27
+ conn.getresponse().read()
28
+
29
+ threading.Thread(target=trigger, daemon=True).start()
30
+ time.sleep(2)
31
+
32
+ from app.core.publish import manager # noqa: E402
33
+
34
+ last = ""
35
+ for i in range(75):
36
+ try:
37
+ b, page = manager._open_page("fanqie")
38
+ url = str(page.url() or "")
39
+ st = page.call("""() => {
40
+ const grab = sel => [...document.querySelectorAll(sel)]
41
+ .filter(e => e.offsetParent && (e.innerText || '').trim())
42
+ .map(e => (e.innerText || '').trim().slice(0, 100));
43
+ return {
44
+ dlg: grab('.arco-modal'),
45
+ toast: grab('[class*=message],[class*=toast],[class*=notification]'),
46
+ btn: grab('button').filter(t => t.length < 12).slice(0, 10)
47
+ };
48
+ }""") or {}
49
+ sig = json.dumps([url, st.get("dlg"), st.get("toast")], ensure_ascii=False)
50
+ if sig != last:
51
+ last = sig
52
+ print("[%03d] %s" % (i, url[:80]))
53
+ if st.get("dlg"):
54
+ print(" 弹窗:", json.dumps(st["dlg"], ensure_ascii=False)[:300])
55
+ if st.get("toast"):
56
+ print(" toast:", json.dumps(st["toast"], ensure_ascii=False)[:300])
57
+ if "book-info" in url:
58
+ print(">>> 跳转 book-info!")
59
+ break
60
+ except Exception as e:
61
+ print("[%03d] watch err: %s" % (i, str(e)[:80]))
62
+ time.sleep(2)
Binary file
@@ -1,110 +1,110 @@
1
- #!/usr/bin/env python3
2
- """原生「选择文件夹」对话框(main.py 的 /api/pick_folder 用)。
3
-
4
- Tk 必须活在自家进程的主线程里:HTTP 请求线程里建 root 在 macOS 上会崩,
5
- Windows 上反复建/销毁也不稳,所以隔离成独立进程。ask_directory() 是父端
6
- 入口,把本文件拉起为子进程,请求参数(JSON:initial 起始目录 / title 标题)
7
- 走 stdin,选中目录以 JSON({"path": ...})走 stdout,用户取消回空串;
8
- 对话框部分是文件尾的 main()。任何一步失败都以非零码退出,父端据此回
9
- fallback=true 让前端回落网页目录弹框。"""
10
- import json
11
- import os
12
- import subprocess
13
- import sys
14
- from pathlib import Path
15
-
16
- # 非 Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(manager 同款守卫)
17
- CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
18
- _SELF = str(Path(__file__).resolve())
19
-
20
-
21
- def ask_directory(initial="", title="选择文件夹"):
22
- """父端入口:拉起子进程弹原生对话框,返回 (path, error, fallback)。
23
-
24
- 用户取消时 path 为空串;子进程起不来(机器无 tkinter 等)时 error
25
- 非空且 fallback=True,调用方原样转给前端回落网页目录弹框。"""
26
- if not Path(__file__).exists():
27
- return "", "pick_dialog.py 缺失", True
28
- req = json.dumps({"initial": initial, "title": title}).encode("utf-8")
29
- env = dict(os.environ, PYTHONIOENCODING="utf-8")
30
- try:
31
- if os.name == "nt":
32
- cp = subprocess.run([sys.executable, _SELF], input=req,
33
- capture_output=True, timeout=600, env=env,
34
- creationflags=CREATE_NO_WINDOW)
35
- else:
36
- cp = subprocess.run([sys.executable, _SELF], input=req,
37
- capture_output=True, timeout=600, env=env)
38
- except Exception as e:
39
- return "", str(e), True
40
- if cp.returncode != 0:
41
- err = (cp.stderr or b"").decode("utf-8", "replace").strip().splitlines()
42
- return "", (err[-1] if err else "native picker unavailable"), True
43
- try:
44
- data = json.loads((cp.stdout or b"").decode("utf-8", "replace") or "{}")
45
- except Exception:
46
- return "", "对话框输出无法解析", True
47
- return str(data.get("path") or ""), "", False
48
-
49
-
50
- def ask_file(initial="", title="选择文件"):
51
- """父端入口:拉起子进程弹原生「选择文件」对话框(导入备份包用)。
52
- 返回 (path, error, fallback),语义与 ask_directory 一致。"""
53
- if not Path(__file__).exists():
54
- return "", "pick_dialog.py 缺失", True
55
- req = json.dumps({"mode": "file", "initial": initial, "title": title}).encode("utf-8")
56
- env = dict(os.environ, PYTHONIOENCODING="utf-8")
57
- try:
58
- if os.name == "nt":
59
- cp = subprocess.run([sys.executable, _SELF], input=req,
60
- capture_output=True, timeout=600, env=env,
61
- creationflags=CREATE_NO_WINDOW)
62
- else:
63
- cp = subprocess.run([sys.executable, _SELF], input=req,
64
- capture_output=True, timeout=600, env=env)
65
- except Exception as e:
66
- return "", str(e), True
67
- if cp.returncode != 0:
68
- err = (cp.stderr or b"").decode("utf-8", "replace").strip().splitlines()
69
- return "", (err[-1] if err else "native picker unavailable"), True
70
- try:
71
- data = json.loads((cp.stdout or b"").decode("utf-8", "replace") or "{}")
72
- except Exception:
73
- return "", "对话框输出无法解析", True
74
- return str(data.get("path") or ""), "", False
75
-
76
-
77
- def main():
78
- req = {}
79
- try:
80
- req = json.loads(sys.stdin.read() or "{}")
81
- except Exception:
82
- pass
83
- import tkinter as tk
84
- from tkinter import filedialog
85
-
86
- root = tk.Tk()
87
- root.withdraw()
88
- try:
89
- root.attributes("-topmost", True) # 别被全屏浏览器盖住
90
- except Exception:
91
- pass
92
- is_file = req.get("mode") == "file"
93
- kw = {"title": req.get("title") or ("选择文件" if is_file else "选择文件夹")}
94
- init = req.get("initial") or ""
95
- if init and os.path.isdir(init):
96
- kw["initialdir"] = init
97
- try:
98
- if is_file:
99
- kw["filetypes"] = [("备份包", "*.zip"), ("全部文件", "*.*")]
100
- path = filedialog.askopenfilename(**kw) or ""
101
- else:
102
- path = filedialog.askdirectory(**kw) or ""
103
- except Exception:
104
- sys.exit(1)
105
- sys.stdout.write(json.dumps({"path": path}))
106
- root.destroy()
107
-
108
-
109
- if __name__ == "__main__":
110
- main()
1
+ #!/usr/bin/env python3
2
+ """原生「选择文件夹」对话框(main.py 的 /api/pick_folder 用)。
3
+
4
+ Tk 必须活在自家进程的主线程里:HTTP 请求线程里建 root 在 macOS 上会崩,
5
+ Windows 上反复建/销毁也不稳,所以隔离成独立进程。ask_directory() 是父端
6
+ 入口,把本文件拉起为子进程,请求参数(JSON:initial 起始目录 / title 标题)
7
+ 走 stdin,选中目录以 JSON({"path": ...})走 stdout,用户取消回空串;
8
+ 对话框部分是文件尾的 main()。任何一步失败都以非零码退出,父端据此回
9
+ fallback=true 让前端回落网页目录弹框。"""
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # 非 Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(manager 同款守卫)
17
+ CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
18
+ _SELF = str(Path(__file__).resolve())
19
+
20
+
21
+ def ask_directory(initial="", title="选择文件夹"):
22
+ """父端入口:拉起子进程弹原生对话框,返回 (path, error, fallback)。
23
+
24
+ 用户取消时 path 为空串;子进程起不来(机器无 tkinter 等)时 error
25
+ 非空且 fallback=True,调用方原样转给前端回落网页目录弹框。"""
26
+ if not Path(__file__).exists():
27
+ return "", "pick_dialog.py 缺失", True
28
+ req = json.dumps({"initial": initial, "title": title}).encode("utf-8")
29
+ env = dict(os.environ, PYTHONIOENCODING="utf-8")
30
+ try:
31
+ if os.name == "nt":
32
+ cp = subprocess.run([sys.executable, _SELF], input=req,
33
+ capture_output=True, timeout=600, env=env,
34
+ creationflags=CREATE_NO_WINDOW)
35
+ else:
36
+ cp = subprocess.run([sys.executable, _SELF], input=req,
37
+ capture_output=True, timeout=600, env=env)
38
+ except Exception as e:
39
+ return "", str(e), True
40
+ if cp.returncode != 0:
41
+ err = (cp.stderr or b"").decode("utf-8", "replace").strip().splitlines()
42
+ return "", (err[-1] if err else "native picker unavailable"), True
43
+ try:
44
+ data = json.loads((cp.stdout or b"").decode("utf-8", "replace") or "{}")
45
+ except Exception:
46
+ return "", "对话框输出无法解析", True
47
+ return str(data.get("path") or ""), "", False
48
+
49
+
50
+ def ask_file(initial="", title="选择文件"):
51
+ """父端入口:拉起子进程弹原生「选择文件」对话框(导入备份包用)。
52
+ 返回 (path, error, fallback),语义与 ask_directory 一致。"""
53
+ if not Path(__file__).exists():
54
+ return "", "pick_dialog.py 缺失", True
55
+ req = json.dumps({"mode": "file", "initial": initial, "title": title}).encode("utf-8")
56
+ env = dict(os.environ, PYTHONIOENCODING="utf-8")
57
+ try:
58
+ if os.name == "nt":
59
+ cp = subprocess.run([sys.executable, _SELF], input=req,
60
+ capture_output=True, timeout=600, env=env,
61
+ creationflags=CREATE_NO_WINDOW)
62
+ else:
63
+ cp = subprocess.run([sys.executable, _SELF], input=req,
64
+ capture_output=True, timeout=600, env=env)
65
+ except Exception as e:
66
+ return "", str(e), True
67
+ if cp.returncode != 0:
68
+ err = (cp.stderr or b"").decode("utf-8", "replace").strip().splitlines()
69
+ return "", (err[-1] if err else "native picker unavailable"), True
70
+ try:
71
+ data = json.loads((cp.stdout or b"").decode("utf-8", "replace") or "{}")
72
+ except Exception:
73
+ return "", "对话框输出无法解析", True
74
+ return str(data.get("path") or ""), "", False
75
+
76
+
77
+ def main():
78
+ req = {}
79
+ try:
80
+ req = json.loads(sys.stdin.read() or "{}")
81
+ except Exception:
82
+ pass
83
+ import tkinter as tk
84
+ from tkinter import filedialog
85
+
86
+ root = tk.Tk()
87
+ root.withdraw()
88
+ try:
89
+ root.attributes("-topmost", True) # 别被全屏浏览器盖住
90
+ except Exception:
91
+ pass
92
+ is_file = req.get("mode") == "file"
93
+ kw = {"title": req.get("title") or ("选择文件" if is_file else "选择文件夹")}
94
+ init = req.get("initial") or ""
95
+ if init and os.path.isdir(init):
96
+ kw["initialdir"] = init
97
+ try:
98
+ if is_file:
99
+ kw["filetypes"] = [("备份包", "*.zip"), ("全部文件", "*.*")]
100
+ path = filedialog.askopenfilename(**kw) or ""
101
+ else:
102
+ path = filedialog.askdirectory(**kw) or ""
103
+ except Exception:
104
+ sys.exit(1)
105
+ sys.stdout.write(json.dumps({"path": path}))
106
+ root.destroy()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()