promptfigure 0.2.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 (42) hide show
  1. package/README.md +95 -0
  2. package/adapters/claude-code/SKILL.md +381 -0
  3. package/adapters/claude-code/install.mjs +9 -0
  4. package/adapters/codex/marketplace.json +14 -0
  5. package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
  6. package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
  7. package/bin/pf.mjs +1953 -0
  8. package/package.json +44 -0
  9. package/scripts/build-adapters.mjs +80 -0
  10. package/scripts/test-e2e.mjs +117 -0
  11. package/skill/promptfigure-local/SKILL.md +381 -0
  12. package/src/anchor.mjs +63 -0
  13. package/src/config.mjs +75 -0
  14. package/src/craft-rules.mjs +158 -0
  15. package/src/craft.mjs +600 -0
  16. package/src/doc/docx.mjs +69 -0
  17. package/src/doc/index.mjs +35 -0
  18. package/src/doc/para.mjs +54 -0
  19. package/src/doc/tex.mjs +161 -0
  20. package/src/doc/texbuild.mjs +158 -0
  21. package/src/docsearch.mjs +96 -0
  22. package/src/entity-pair.mjs +17 -0
  23. package/src/events.mjs +52 -0
  24. package/src/extract.mjs +124 -0
  25. package/src/figure-catalog.mjs +326 -0
  26. package/src/journal.mjs +67 -0
  27. package/src/ledger.mjs +43 -0
  28. package/src/next.mjs +125 -0
  29. package/src/plan.mjs +112 -0
  30. package/src/png-trim.mjs +217 -0
  31. package/src/quality.mjs +325 -0
  32. package/src/ratio.mjs +87 -0
  33. package/src/render.mjs +247 -0
  34. package/src/review.mjs +48 -0
  35. package/src/server.mjs +218 -0
  36. package/src/store.mjs +91 -0
  37. package/src/vectorize.mjs +54 -0
  38. package/tray/pf-tray.py +265 -0
  39. package/web/app.js +613 -0
  40. package/web/index.html +73 -0
  41. package/web/probe.html +36 -0
  42. package/web/style.css +208 -0
@@ -0,0 +1,265 @@
1
+ # pf-tray.py — promptFigure 本地插件托盘壳(第二阶段)
2
+ # 职责:常驻托盘保护 daemon;GUI 窗口随便关,服务不随窗口死
3
+ # 依赖:pystray + pillow(pip install pystray pillow)
4
+ # 里面每 10s 探测 daemon.json 对应进程是否存活,死了自动拉起(复用 pf-local-media-server 托盘的自愈思路)
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from PIL import Image, ImageDraw
13
+ import pystray
14
+
15
+ PF_DIR = Path.home() / ".promptfigure"
16
+ DAEMON_JSON = PF_DIR / "daemon.json"
17
+ TRAY_JSON = PF_DIR / "tray.json" # 托盘心跳(pf open 用来判断托盘是否已在跑)
18
+ STOP_FLAG = PF_DIR / "stopped.flag" # pf stop 写入:自愈线程看到就不再拉活服务
19
+ PLUGIN_ROOT = Path(__file__).resolve().parent.parent
20
+ SERVER_MJS = PLUGIN_ROOT / "src" / "server.mjs"
21
+ PF_CLI = PLUGIN_ROOT / "bin" / "pf.mjs"
22
+ NODE = "node"
23
+
24
+ DEFAULT_PORT = 17420
25
+
26
+
27
+ def read_daemon():
28
+ try:
29
+ return json.loads(DAEMON_JSON.read_text(encoding="utf-8"))
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def daemon_alive():
35
+ """daemon.json 里的 pid 活着 & 端口能连上才算活(两个条件防僵尸记录)"""
36
+ d = read_daemon()
37
+ if not d:
38
+ return None
39
+ pid = d.get("pid")
40
+ if pid:
41
+ try:
42
+ # Windows: 打开进程探测
43
+ import ctypes
44
+ k32 = ctypes.windll.kernel32
45
+ h = k32.OpenProcess(0x1000, False, int(pid)) # PROCESS_QUERY_LIMITED_INFORMATION
46
+ if h:
47
+ k32.CloseHandle(h)
48
+ else:
49
+ return None # 进程没了
50
+ except Exception:
51
+ return None
52
+ # 端口探测
53
+ import socket
54
+ try:
55
+ with socket.create_connection(("127.0.0.1", int(d["port"])), timeout=1):
56
+ return d
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def port_free(port):
62
+ """端口能否绑定(被孤儿进程占着 = 不自由)"""
63
+ import socket
64
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
65
+ try:
66
+ s.bind(("127.0.0.1", int(port)))
67
+ return True
68
+ except Exception:
69
+ return False
70
+ finally:
71
+ s.close()
72
+
73
+
74
+ def start_daemon():
75
+ # 🔴 端口回退(2026-09-21 实测):孤儿进程占着默认端口时,原逻辑会每 10s 在同端口
76
+ # 重试到天荒地老。逐个 +1 试到 5,新端口由 server 写进 daemon.json,CLI 自动跟上
77
+ base = int(read_daemon().get("port") if read_daemon() else DEFAULT_PORT)
78
+ # 🔴 stdio 必须重定向到真实文件(2026-09-21):托盘从 Startup vbs(pythonw,无控制台)
79
+ # 启动时子进程继承无效句柄 —— 与 pf.mjs 里 stdio:'ignore'(NUL) 弄死 pythonw 是同类坑;
80
+ # 且日志留下 daemon 秒死时的临终输出(此前 daemon 死因零证据)
81
+ log_path = PF_DIR / "logs" / "daemon.log"
82
+ try:
83
+ log_path.parent.mkdir(parents=True, exist_ok=True)
84
+ logf = open(log_path, "a", encoding="utf-8", errors="replace")
85
+ logf.write(f"\n---- tray start_daemon {time.strftime('%Y-%m-%dT%H:%M:%S')} ----\n")
86
+ logf.flush()
87
+ except Exception:
88
+ logf = None
89
+ for offset in range(0, 6):
90
+ port = base + offset
91
+ if not port_free(port):
92
+ continue
93
+ kwargs = {}
94
+ if logf is not None:
95
+ kwargs = {"stdout": logf, "stderr": logf}
96
+ subprocess.Popen(
97
+ [NODE, str(SERVER_MJS), str(port)],
98
+ creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP,
99
+ cwd=str(PLUGIN_ROOT),
100
+ **kwargs,
101
+ )
102
+ for _ in range(20):
103
+ time.sleep(0.5)
104
+ if daemon_alive():
105
+ return True
106
+ return False
107
+
108
+
109
+ def gui_url():
110
+ d = read_daemon()
111
+ if not d:
112
+ return None
113
+ docs = sorted((PF_DIR / "projects").glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
114
+ doc = f"?doc={docs[0].name}&" if docs else "?"
115
+ return f"http://127.0.0.1:{d['port']}/{doc}token={d['guiToken']}"
116
+
117
+
118
+ def on_open(icon, item):
119
+ url = gui_url()
120
+ if url:
121
+ os.startfile(url) # 默认浏览器;独立窗口用 pf open
122
+ else:
123
+ start_daemon()
124
+ url = gui_url()
125
+ if url:
126
+ os.startfile(url)
127
+
128
+
129
+ def on_restart(icon, item):
130
+ notify(icon, "正在重启本地服务…")
131
+ try:
132
+ d = read_daemon()
133
+ if d and d.get("pid"):
134
+ subprocess.run(["taskkill", "/PID", str(d["pid"]), "/F"], capture_output=True)
135
+ except Exception:
136
+ pass
137
+ ok = start_daemon()
138
+ notify(icon, "服务已重启" if ok else "重启失败(看日志)")
139
+
140
+
141
+ def on_quit(icon, item):
142
+ """退出 = 真退出:托盘和本地服务一起停,不留看不见的后台进程"""
143
+ try:
144
+ d = read_daemon()
145
+ if d and d.get("pid"):
146
+ subprocess.run(["taskkill", "/PID", str(d["pid"]), "/F"], capture_output=True)
147
+ if DAEMON_JSON.exists():
148
+ DAEMON_JSON.unlink()
149
+ except Exception:
150
+ pass
151
+ icon.stop()
152
+
153
+
154
+ def notify(icon, msg):
155
+ try:
156
+ icon.notify(msg, "promptFigure")
157
+ except Exception:
158
+ pass
159
+
160
+
161
+ def make_image():
162
+ # 画一个简洁的"图表"图标:深蓝底 + 白色柱状 + 黄色高亮点
163
+ img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
164
+ dr = ImageDraw.Draw(img)
165
+ dr.rounded_rectangle([4, 4, 60, 60], radius=14, fill=(28, 60, 120, 255))
166
+ for i, (x, h) in enumerate([(16, 20), (27, 32), (38, 14), (45, 26)]):
167
+ dr.rounded_rectangle([x, 48 - h, x + 8, 48], radius=3, fill=(255, 255, 255, 235))
168
+ dr.ellipse([41, 8, 53, 20], fill=(255, 196, 0, 255))
169
+ return img
170
+
171
+
172
+ def heartbeat():
173
+ try:
174
+ TRAY_JSON.write_text(json.dumps({"pid": os.getpid(), "t": time.time()}), encoding="utf-8")
175
+ except Exception:
176
+ pass
177
+
178
+
179
+ def watch_loop():
180
+ """自愈线程:每 10s 探测,挂了自动拉起;pf stop 写了 stopped.flag 就不再拉(尊重手动停止)"""
181
+ while True:
182
+ try:
183
+ heartbeat()
184
+ if STOP_FLAG.exists():
185
+ pass # 用户/CLI 明确停过服务,别自作主张复活
186
+ elif not daemon_alive():
187
+ start_daemon()
188
+ except Exception:
189
+ pass
190
+ time.sleep(10)
191
+
192
+
193
+ def existing_instance_alive():
194
+ """单实例守卫:tray.json 心跳新鲜且 pid 活着 = 已有托盘在跑(pf open 多次调用 /
195
+ schtasks 重试都会重复拉起,两个图标两个自愈循环打架 —— 2026-09-21 实测)"""
196
+ try:
197
+ d = json.loads(TRAY_JSON.read_text(encoding="utf-8"))
198
+ except Exception:
199
+ return False
200
+ if time.time() - float(d.get("t", 0)) > 40:
201
+ return False
202
+ pid = d.get("pid")
203
+ if not pid or int(pid) == os.getpid():
204
+ return False
205
+ try:
206
+ import ctypes
207
+ k32 = ctypes.windll.kernel32
208
+ h = k32.OpenProcess(0x1000, False, int(pid))
209
+ if h:
210
+ k32.CloseHandle(h)
211
+ return True
212
+ return False
213
+ except Exception:
214
+ return False
215
+
216
+
217
+ def kill_stale_tray():
218
+ """🔴 心跳停更但进程还在 = 冻结的旧实例(宿主会话挂起/睡眠恢复残留,2026-09-21 实测)。
219
+ 不清理的话旧实例一旦恢复,两个自愈循环抢着拉 daemon 打架。单实例守卫只挡"活"实例,
220
+ 这里负责补刀僵尸。"""
221
+ try:
222
+ d = json.loads(TRAY_JSON.read_text(encoding="utf-8"))
223
+ pid = int(d.get("pid") or 0)
224
+ if not pid or pid == os.getpid():
225
+ return
226
+ t = float(d.get("t", 0))
227
+ if time.time() - t <= 40: # 心跳还新鲜 = 真活实例,别动(与 existing_instance_alive 同阈值)
228
+ return
229
+ subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
230
+ except Exception:
231
+ pass
232
+
233
+
234
+ def main():
235
+ # 单实例守卫放在心跳之前:已有实例在跑就静默退出,不覆盖它的心跳
236
+ if existing_instance_alive():
237
+ return
238
+ # 守卫放过的 = 心跳已停更,若旧进程还挂着(冻结态)先补刀
239
+ kill_stale_tray()
240
+ # 🔴 心跳必须是第一件事 —— pf open/ensureTray 靠它在几秒内判断托盘起没起来,
241
+ # 放到 start_daemon 之后会错过 CLI 的等待窗口(实测被判"托盘启动失败")
242
+ heartbeat()
243
+ # 显式启动托盘 = 用户想要服务,清掉手动停止标记
244
+ try:
245
+ if STOP_FLAG.exists():
246
+ STOP_FLAG.unlink()
247
+ except Exception:
248
+ pass
249
+ if not daemon_alive():
250
+ start_daemon()
251
+ import threading
252
+ threading.Thread(target=watch_loop, daemon=True).start()
253
+
254
+ menu = pystray.Menu(
255
+ pystray.MenuItem("打开工作台", on_open, default=True),
256
+ pystray.MenuItem("重启本地服务", on_restart),
257
+ pystray.MenuItem("退出(停止托盘并关闭服务)", on_quit),
258
+ )
259
+ icon = pystray.Icon("promptFigure", make_image(), "promptFigure 本地插件", menu)
260
+ notify(icon, "托盘已启动,服务受保护")
261
+ icon.run()
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()