codebee 0.1.74 → 0.1.77

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 (119) hide show
  1. package/CHANGELOG.md +628 -607
  2. package/LICENSE +21 -21
  3. package/README.md +507 -508
  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/aiflavor.py +197 -197
  13. package/app/core/analytics.py +38 -38
  14. package/app/core/artifacts.py +148 -148
  15. package/app/core/assets.py +110 -110
  16. package/app/core/attachments.py +623 -623
  17. package/app/core/audit.py +30 -30
  18. package/app/core/backup.py +470 -470
  19. package/app/core/beekeeper.py +183 -183
  20. package/app/core/benchstore.py +95 -95
  21. package/app/core/board.py +209 -209
  22. package/app/core/bookmeta.py +1036 -921
  23. package/app/core/bookmeta_catalog.py +180 -180
  24. package/app/core/branching.py +137 -137
  25. package/app/core/builtin_agent.py +1680 -1680
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/compaction.py +201 -201
  30. package/app/core/contracts.py +255 -255
  31. package/app/core/covergen.py +356 -356
  32. package/app/core/defectretro.py +43 -43
  33. package/app/core/diagnostics.py +114 -114
  34. package/app/core/dispatch.py +190 -190
  35. package/app/core/dispatch_log.py +128 -128
  36. package/app/core/env_scrub.py +85 -85
  37. package/app/core/errorlog.py +137 -137
  38. package/app/core/evaluation.py +174 -174
  39. package/app/core/failure_notify.py +290 -290
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +389 -387
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +22 -0
  45. package/app/core/manager.py +2232 -2232
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +189 -189
  49. package/app/core/openapi.py +154 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +189 -18
  53. package/app/core/planner.py +88 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/browser.py +655 -655
  60. package/app/core/publish/fanqie.py +165 -165
  61. package/app/core/publish/flow.py +640 -640
  62. package/app/core/publish/flows-fanqie-calibrated.json +336 -336
  63. package/app/core/publish/manager.py +1118 -1118
  64. package/app/core/publish/qimao.py +176 -176
  65. package/app/core/publish/ws.py +139 -139
  66. package/app/core/quality_gate.py +194 -194
  67. package/app/core/redact.py +38 -38
  68. package/app/core/repeat_guard.py +129 -129
  69. package/app/core/retrieval.py +137 -137
  70. package/app/core/router.py +398 -398
  71. package/app/core/runner.py +2238 -2225
  72. package/app/core/selfupdate.py +384 -384
  73. package/app/core/session_log.py +161 -161
  74. package/app/core/sessions.py +312 -312
  75. package/app/core/settings_schema.py +270 -270
  76. package/app/core/share_page.py +114 -114
  77. package/app/core/skill_scan.py +139 -139
  78. package/app/core/skillpacks/market/character-bible.md +66 -66
  79. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  80. package/app/core/skillpacks/market/git-workflow.md +57 -57
  81. package/app/core/skillpacks/market/release-notes.md +72 -72
  82. package/app/core/skillpacks/market/weekly-report.md +71 -71
  83. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  84. package/app/core/skills.py +1 -1
  85. package/app/core/step_runner.py +132 -132
  86. package/app/core/story_tracking.py +275 -241
  87. package/app/core/task_compile.py +199 -199
  88. package/app/core/telemetry.py +292 -292
  89. package/app/core/tlsctx.py +64 -64
  90. package/app/core/token_meter.py +147 -147
  91. package/app/core/tracing.py +144 -144
  92. package/app/core/upstream.py +41 -41
  93. package/app/core/usage.py +1035 -1035
  94. package/app/core/volumes.py +389 -389
  95. package/app/core/wxdigest.py +858 -858
  96. package/app/core/zentao.py +2692 -2692
  97. package/app/main.py +59 -0
  98. package/app/mcp_server.py +244 -244
  99. package/app/output/_createbook_body.json +1 -0
  100. package/app/output/_probe_fq_click.py +50 -0
  101. package/app/output/_probe_fq_form.py +42 -0
  102. package/app/output/_probe_fq_modal.py +55 -0
  103. package/app/output/_probe_fq_points.py +71 -0
  104. package/app/output/_probe_fq_submit.py +34 -0
  105. package/app/output/_probe_fq_watch.py +62 -0
  106. package/app/pick_dialog.py +110 -110
  107. package/app/ui/app.js +108 -12
  108. package/app/ui/artdiff.js +74 -74
  109. package/app/ui/board.html +162 -162
  110. package/app/ui/board.js +305 -305
  111. package/app/ui/i18n.js +15 -0
  112. package/app/ui/icons/bee.svg +79 -79
  113. package/app/ui/index.html +4 -0
  114. package/app/ui/manifest.json +16 -16
  115. package/app/ui/qrcode.js +2297 -2297
  116. package/app/ui/style.css +7086 -7070
  117. package/bin/tutti.js +147 -147
  118. package/package.json +48 -48
  119. package/tools/wx_reader.py +261 -261
@@ -0,0 +1,42 @@
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
+ print("url:", page.url())
12
+ info = page.call("""() => {
13
+ const out = {errors: [], fields: []};
14
+ // arco 校验错误信息
15
+ [...document.querySelectorAll('.arco-form-message,[class*=error],[class*=message]')]
16
+ .filter(e => e.offsetParent && (e.innerText || '').trim())
17
+ .slice(0, 10)
18
+ .forEach(e => out.errors.push((e.innerText || '').trim().slice(0, 80)));
19
+ // 各输入控件现值
20
+ [...document.querySelectorAll('input[placeholder],textarea[placeholder]')]
21
+ .slice(0, 12)
22
+ .forEach(e => out.fields.push({
23
+ ph: e.placeholder, tag: e.tagName,
24
+ val: (e.value || '').slice(0, 60), len: (e.value || '').length}));
25
+ // contenteditable(简介可能是富文本)
26
+ [...document.querySelectorAll('[contenteditable=true]')]
27
+ .slice(0, 4)
28
+ .forEach(e => out.fields.push({
29
+ ph: '(contenteditable:' + String(e.className).slice(0, 30) + ')',
30
+ tag: 'CE', val: (e.innerText || '').slice(0, 60),
31
+ len: (e.innerText || '').length}));
32
+ out.body_tail = document.body.innerText.slice(-400).split('\\n').join('|');
33
+ return out;
34
+ }""")
35
+ print(json.dumps(info, ensure_ascii=False, indent=1))
36
+
37
+ # 对照 book_meta 的简介长度
38
+ meta = json.load(open(
39
+ r"C:\Users\HP\AppData\Roaming\codebee\tasks\t-20260924-215431-0715.json",
40
+ encoding="utf-8"))
41
+ s = (meta.get("book_meta", {}).get("fanqie", {}).get("data", {}).get("summary") or "")
42
+ print("book_meta summary 长度:", len(s))
@@ -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)
@@ -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()