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.
- package/README.md +95 -0
- package/adapters/claude-code/SKILL.md +381 -0
- package/adapters/claude-code/install.mjs +9 -0
- package/adapters/codex/marketplace.json +14 -0
- package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
- package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
- package/bin/pf.mjs +1953 -0
- package/package.json +44 -0
- package/scripts/build-adapters.mjs +80 -0
- package/scripts/test-e2e.mjs +117 -0
- package/skill/promptfigure-local/SKILL.md +381 -0
- package/src/anchor.mjs +63 -0
- package/src/config.mjs +75 -0
- package/src/craft-rules.mjs +158 -0
- package/src/craft.mjs +600 -0
- package/src/doc/docx.mjs +69 -0
- package/src/doc/index.mjs +35 -0
- package/src/doc/para.mjs +54 -0
- package/src/doc/tex.mjs +161 -0
- package/src/doc/texbuild.mjs +158 -0
- package/src/docsearch.mjs +96 -0
- package/src/entity-pair.mjs +17 -0
- package/src/events.mjs +52 -0
- package/src/extract.mjs +124 -0
- package/src/figure-catalog.mjs +326 -0
- package/src/journal.mjs +67 -0
- package/src/ledger.mjs +43 -0
- package/src/next.mjs +125 -0
- package/src/plan.mjs +112 -0
- package/src/png-trim.mjs +217 -0
- package/src/quality.mjs +325 -0
- package/src/ratio.mjs +87 -0
- package/src/render.mjs +247 -0
- package/src/review.mjs +48 -0
- package/src/server.mjs +218 -0
- package/src/store.mjs +91 -0
- package/src/vectorize.mjs +54 -0
- package/tray/pf-tray.py +265 -0
- package/web/app.js +613 -0
- package/web/index.html +73 -0
- package/web/probe.html +36 -0
- package/web/style.css +208 -0
package/tray/pf-tray.py
ADDED
|
@@ -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()
|