folderui 0.1.0__py3-none-any.whl
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.
- folderui/__init__.py +6 -0
- folderui/fsutil.py +28 -0
- folderui/py.typed +0 -0
- folderui/server.py +68 -0
- folderui/session.py +44 -0
- folderui/ui.py +261 -0
- folderui-0.1.0.dist-info/METADATA +140 -0
- folderui-0.1.0.dist-info/RECORD +10 -0
- folderui-0.1.0.dist-info/WHEEL +4 -0
- folderui-0.1.0.dist-info/licenses/LICENSE +21 -0
folderui/__init__.py
ADDED
folderui/fsutil.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""檔案工具:所有寫入都是原子的(先寫暫存檔再 rename),避免讀到半個檔案。"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_text(path: Path, text: str, mode: int | None = None) -> None:
|
|
8
|
+
tmp = path.with_name(f".{path.name}.tmp")
|
|
9
|
+
tmp.write_text(text, encoding="utf-8")
|
|
10
|
+
if mode is not None:
|
|
11
|
+
os.chmod(tmp, mode)
|
|
12
|
+
os.replace(tmp, path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_json(path: Path, data) -> None:
|
|
16
|
+
write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_text(path: Path, default=None):
|
|
20
|
+
try:
|
|
21
|
+
return path.read_text(encoding="utf-8")
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_json(path: Path, default=None):
|
|
27
|
+
text = read_text(path)
|
|
28
|
+
return default if text is None else json.loads(text)
|
folderui/py.typed
ADDED
|
File without changes
|
folderui/server.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""多人用的伺服器:一個執行緒依序處理所有客戶端的請求,並推播給客戶端。
|
|
2
|
+
|
|
3
|
+
客戶端 ──call(who, cmd)──▶ Server.handle() 請求/回應(同步)
|
|
4
|
+
客戶端 ◀──push(who, msg)── Server 推播(非同步)
|
|
5
|
+
|
|
6
|
+
handle() raise 的例外會傳回呼叫端(ValueError 代表規則不允許)。
|
|
7
|
+
推播走客戶端的無上限佇列,伺服器永遠不會等待客戶端,所以不會死鎖。
|
|
8
|
+
"""
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Server:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.clients: dict = {}
|
|
17
|
+
self._requests: queue.Queue = queue.Queue()
|
|
18
|
+
self._stopped = False
|
|
19
|
+
|
|
20
|
+
# ---- 子類別實作 ----
|
|
21
|
+
|
|
22
|
+
def handle(self, who, cmd):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def on_start(self) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
# ---- API ----
|
|
29
|
+
|
|
30
|
+
def connect(self, who, client) -> None:
|
|
31
|
+
"""註冊一個可以接收推播的客戶端(任何有 push(msg) 的物件)。"""
|
|
32
|
+
self.clients[who] = client
|
|
33
|
+
|
|
34
|
+
def call(self, who, cmd, timeout: float = 30):
|
|
35
|
+
box: queue.Queue = queue.Queue(maxsize=1)
|
|
36
|
+
self._requests.put((who, cmd, box))
|
|
37
|
+
try:
|
|
38
|
+
ok, value = box.get(timeout=timeout)
|
|
39
|
+
except queue.Empty:
|
|
40
|
+
raise TimeoutError("伺服器沒有回應") from None
|
|
41
|
+
if ok:
|
|
42
|
+
return value
|
|
43
|
+
raise value
|
|
44
|
+
|
|
45
|
+
def push(self, who, msg) -> None:
|
|
46
|
+
self.clients[who].push(msg)
|
|
47
|
+
|
|
48
|
+
def start(self) -> threading.Thread:
|
|
49
|
+
t = threading.Thread(target=self.run, name="folderui:server", daemon=True)
|
|
50
|
+
t.start()
|
|
51
|
+
return t
|
|
52
|
+
|
|
53
|
+
def stop(self) -> None:
|
|
54
|
+
self._stopped = True
|
|
55
|
+
|
|
56
|
+
def run(self) -> None:
|
|
57
|
+
self.on_start()
|
|
58
|
+
while not self._stopped:
|
|
59
|
+
try:
|
|
60
|
+
who, cmd, box = self._requests.get(timeout=0.2)
|
|
61
|
+
except queue.Empty:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
box.put((True, self.handle(who, cmd)))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if not isinstance(e, ValueError):
|
|
67
|
+
traceback.print_exc()
|
|
68
|
+
box.put((False, e))
|
folderui/session.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""程式型 agent 用的客戶端:和按鈕腳本走同一個檔案協定。"""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .fsutil import read_text, write_text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Session:
|
|
10
|
+
def __init__(self, path: str | Path, poll: float = 0.1):
|
|
11
|
+
self.root = Path(path)
|
|
12
|
+
self.poll = poll
|
|
13
|
+
|
|
14
|
+
def ready(self, timeout: float | None = None) -> bool:
|
|
15
|
+
"""等到資料夾畫好(第一次可以讀到按鈕列表)。"""
|
|
16
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
17
|
+
while not (self.root / ".buttons.json").exists():
|
|
18
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
19
|
+
return False
|
|
20
|
+
time.sleep(self.poll)
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
def buttons(self) -> dict[str, dict]:
|
|
24
|
+
"""目前可按的按鈕 {路徑: {hint, options}};空的代表已結束(或還輪不到你)。"""
|
|
25
|
+
return json.loads(read_text(self.root / ".buttons.json", "{}"))
|
|
26
|
+
|
|
27
|
+
def read(self, path: str) -> str:
|
|
28
|
+
return read_text(self.root / path, "")
|
|
29
|
+
|
|
30
|
+
def press(self, path: str, *args: str, timeout: float | None = None) -> tuple[bool, str]:
|
|
31
|
+
"""按下按鈕並等待回應,回傳 (是否成功, 回應內容)。"""
|
|
32
|
+
request = self.root / ".request"
|
|
33
|
+
if request.exists():
|
|
34
|
+
raise RuntimeError("上一個操作還在等待回應")
|
|
35
|
+
(self.root / ".done").unlink(missing_ok=True)
|
|
36
|
+
write_text(request, "".join(f"{x}\0" for x in (path, *args)))
|
|
37
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
38
|
+
while request.exists():
|
|
39
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
40
|
+
raise TimeoutError("等待回應逾時")
|
|
41
|
+
time.sleep(self.poll)
|
|
42
|
+
code, _, text = read_text(self.root / ".response", "1\n").partition("\n")
|
|
43
|
+
(self.root / ".done").unlink(missing_ok=True)
|
|
44
|
+
return code.strip() == "0", text.rstrip("\n")
|
folderui/ui.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""FolderUI:把一個資料夾變成給人類或 AI agent 用的立即式 GUI。
|
|
2
|
+
|
|
3
|
+
子類別實作三個方法:
|
|
4
|
+
draw() 每一幀重新宣告畫面:file()、button()、reply()。不可修改狀態。
|
|
5
|
+
action(name, args) 按鈕被按下時呼叫:回傳文字,或 raise 代表失敗。只有這裡修改狀態。
|
|
6
|
+
receive(msg) 收到 push() 的訊息(例如伺服器推播)。
|
|
7
|
+
|
|
8
|
+
資料夾裡只有 draw() 宣告的檔案和按鈕腳本(<path>.sh)。執行按鈕腳本後,腳本會一直等待,
|
|
9
|
+
直到「操作處理完之後,第一個有按鈕可按的畫面」出現(或 close())才回應:
|
|
10
|
+
action 的回傳值 + 這段期間的 notify() + reply() + 目前可按的按鈕
|
|
11
|
+
|
|
12
|
+
腳本與主機之間的協定(隱藏檔):
|
|
13
|
+
.request 腳本寫入:按鈕路徑與參數,以 NUL 分隔
|
|
14
|
+
.response 主機寫入:第一行為 exit code,之後為回應內容
|
|
15
|
+
.done 主機回應後把 .request 改名為 .done;腳本讀完回應後刪除它
|
|
16
|
+
.buttons.json 目前的按鈕列表(給程式型 agent 用)
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import traceback
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .fsutil import write_text
|
|
29
|
+
|
|
30
|
+
PROTOCOL_FILES = (".request", ".request.new", ".response", ".done")
|
|
31
|
+
|
|
32
|
+
SCRIPT = """\
|
|
33
|
+
#!/bin/sh
|
|
34
|
+
# folderui 按鈕:{name}{hint}
|
|
35
|
+
cd {root} || exit 1
|
|
36
|
+
printf '%s\\0' {qname} "$@" > .request.new
|
|
37
|
+
if [ -e .request ]; then
|
|
38
|
+
if cmp -s .request .request.new; then
|
|
39
|
+
rm -f .request.new
|
|
40
|
+
echo "⏳ 繼續等待…" >&2
|
|
41
|
+
else
|
|
42
|
+
rm -f .request.new
|
|
43
|
+
echo "❌ 上一個操作還在等待回應;再執行一次同樣的指令可以繼續等待"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
elif [ -e .done ] && cmp -s .done .request.new; then
|
|
47
|
+
rm -f .request.new
|
|
48
|
+
else
|
|
49
|
+
rm -f .done .response
|
|
50
|
+
mv .request.new .request
|
|
51
|
+
echo "⏳ 已送出,等待中…" >&2
|
|
52
|
+
fi
|
|
53
|
+
limit=$(( ${{FOLDERUI_TIMEOUT:-0}} * 5 )); i=0
|
|
54
|
+
while [ -e .request ]; do
|
|
55
|
+
sleep 0.2; i=$((i+1))
|
|
56
|
+
if [ "$limit" -gt 0 ] && [ "$i" -ge "$limit" ]; then
|
|
57
|
+
echo "⏳ 還在等待中,再執行一次同樣的指令即可繼續等待"
|
|
58
|
+
exit 2
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
code=$(head -n 1 .response)
|
|
62
|
+
tail -n +2 .response
|
|
63
|
+
rm -f .done
|
|
64
|
+
exit "$code"
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_path(path: str) -> str:
|
|
69
|
+
parts = path.split("/")
|
|
70
|
+
if path.startswith("/") or any(not p or p.startswith(".") for p in parts):
|
|
71
|
+
raise ValueError(f"路徑不能是絕對路徑、空段落或隱藏檔:{path!r}")
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FolderUI:
|
|
76
|
+
poll = 0.1
|
|
77
|
+
close_grace = 5.0
|
|
78
|
+
|
|
79
|
+
def __init__(self, path: str | Path):
|
|
80
|
+
self.root = Path(path).resolve()
|
|
81
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
self.closed = False
|
|
83
|
+
self._inbox: queue.Queue = queue.Queue()
|
|
84
|
+
self._files: dict[str, str] = {}
|
|
85
|
+
self._buttons: dict[str, tuple[str, list[str] | None]] = {}
|
|
86
|
+
self._reply = ""
|
|
87
|
+
self._disk_files: dict[str, str] = {} # 磁碟上由本框架管理的檔案
|
|
88
|
+
self._disk_scripts: dict[str, str] = {}
|
|
89
|
+
self._notes: list[str] = []
|
|
90
|
+
self._pending: tuple[int, str] | None = None
|
|
91
|
+
self._pending_button: tuple[str, str] | None = None # (路徑, hint):回應被讀走前保留它的腳本
|
|
92
|
+
|
|
93
|
+
# ---- 子類別實作 ----
|
|
94
|
+
|
|
95
|
+
def draw(self) -> None:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def action(self, name: str, args: list[str]) -> str | None:
|
|
99
|
+
raise ValueError(f"沒有處理按鈕 {name}")
|
|
100
|
+
|
|
101
|
+
def receive(self, msg) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# ---- 在 draw() 中呼叫 ----
|
|
105
|
+
|
|
106
|
+
def file(self, path: str, content: str) -> None:
|
|
107
|
+
self._files[_check_path(path)] = content
|
|
108
|
+
|
|
109
|
+
def button(self, path: str, hint: str = "", options: list[str] | None = None) -> None:
|
|
110
|
+
self._buttons[_check_path(path)] = (hint, options)
|
|
111
|
+
|
|
112
|
+
def reply(self, text: str) -> None:
|
|
113
|
+
self._reply = text
|
|
114
|
+
|
|
115
|
+
# ---- 在 action() / receive() 中呼叫 ----
|
|
116
|
+
|
|
117
|
+
def notify(self, text: str) -> None:
|
|
118
|
+
"""累積一則消息,在下一次回應時一起送出。"""
|
|
119
|
+
self._notes.append(text)
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
"""結束:移除所有按鈕,回應正在等待的腳本,然後停止。"""
|
|
123
|
+
self.closed = True
|
|
124
|
+
|
|
125
|
+
# ---- 給其他執行緒 ----
|
|
126
|
+
|
|
127
|
+
def push(self, msg) -> None:
|
|
128
|
+
self._inbox.put(msg)
|
|
129
|
+
|
|
130
|
+
def start(self) -> threading.Thread:
|
|
131
|
+
t = threading.Thread(target=self.run, name=f"folderui:{self.root.name}", daemon=True)
|
|
132
|
+
t.start()
|
|
133
|
+
return t
|
|
134
|
+
|
|
135
|
+
# ---- 主迴圈 ----
|
|
136
|
+
|
|
137
|
+
def run(self) -> None:
|
|
138
|
+
for name in PROTOCOL_FILES:
|
|
139
|
+
(self.root / name).unlink(missing_ok=True)
|
|
140
|
+
self._redraw()
|
|
141
|
+
deadline = None
|
|
142
|
+
while not (self.closed and self._pending is None and self._pending_button is None):
|
|
143
|
+
if self.closed and self._pending is None: # 已結束:最後的回應最多等 close_grace 秒被讀走
|
|
144
|
+
deadline = deadline or time.monotonic() + self.close_grace
|
|
145
|
+
if time.monotonic() > deadline:
|
|
146
|
+
break
|
|
147
|
+
changed = False
|
|
148
|
+
try:
|
|
149
|
+
msg = self._inbox.get(timeout=self.poll)
|
|
150
|
+
self._call(self.receive, msg)
|
|
151
|
+
changed = True
|
|
152
|
+
except queue.Empty:
|
|
153
|
+
pass
|
|
154
|
+
changed |= self._take_request()
|
|
155
|
+
changed |= self._drain()
|
|
156
|
+
if self._pending is None and self._pending_button and not (self.root / ".done").exists():
|
|
157
|
+
self._pending_button = None # 回應已被讀走
|
|
158
|
+
changed = True
|
|
159
|
+
if changed:
|
|
160
|
+
self._redraw()
|
|
161
|
+
if self._pending is not None and (self._buttons or self.closed):
|
|
162
|
+
self._respond()
|
|
163
|
+
|
|
164
|
+
def _drain(self) -> bool:
|
|
165
|
+
changed = False
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
msg = self._inbox.get_nowait()
|
|
169
|
+
except queue.Empty:
|
|
170
|
+
return changed
|
|
171
|
+
self._call(self.receive, msg)
|
|
172
|
+
changed = True
|
|
173
|
+
|
|
174
|
+
def _take_request(self) -> bool:
|
|
175
|
+
if self._pending is not None:
|
|
176
|
+
return False
|
|
177
|
+
try:
|
|
178
|
+
raw = (self.root / ".request").read_bytes()
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
return False
|
|
181
|
+
name, *args = [x.decode("utf-8", "replace") for x in raw.split(b"\0")[:-1]] or [""]
|
|
182
|
+
if name not in self._buttons:
|
|
183
|
+
self._pending = (1, f"❌ 按鈕 {name} 已經不存在")
|
|
184
|
+
return True
|
|
185
|
+
self._pending_button = (name, self._buttons[name][0])
|
|
186
|
+
try:
|
|
187
|
+
result = self.action(name, args)
|
|
188
|
+
self._pending = (0, result or "")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if not isinstance(e, ValueError): # ValueError 視為使用者錯誤,其餘是程式 bug
|
|
191
|
+
traceback.print_exc()
|
|
192
|
+
self._pending = (1, f"❌ {e}")
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def _respond(self) -> None:
|
|
196
|
+
code, text = self._pending
|
|
197
|
+
parts = [text] if text else []
|
|
198
|
+
parts += self._notes
|
|
199
|
+
if self._reply:
|
|
200
|
+
parts.append(self._reply)
|
|
201
|
+
if self.closed:
|
|
202
|
+
parts.append("── 已結束 ──")
|
|
203
|
+
else:
|
|
204
|
+
lines = ["── 可以做的事 ──"]
|
|
205
|
+
for path, (hint, options) in self._buttons.items():
|
|
206
|
+
lines.append(f"./{path}.sh" + (f" {hint}" if hint else ""))
|
|
207
|
+
if options:
|
|
208
|
+
lines.append(" 選項:" + " / ".join(options))
|
|
209
|
+
parts.append("\n".join(lines))
|
|
210
|
+
write_text(self.root / ".response", f"{code}\n" + "\n\n".join(parts) + "\n")
|
|
211
|
+
os.replace(self.root / ".request", self.root / ".done")
|
|
212
|
+
self._notes = []
|
|
213
|
+
self._pending = None
|
|
214
|
+
|
|
215
|
+
def _redraw(self) -> None:
|
|
216
|
+
self._files, self._buttons, self._reply = {}, {}, ""
|
|
217
|
+
self._call(self.draw)
|
|
218
|
+
if self.closed:
|
|
219
|
+
self._buttons = {}
|
|
220
|
+
scripts = {f"{p}.sh": self._script(p, hint) for p, (hint, _) in self._buttons.items()}
|
|
221
|
+
if self._pending_button: # 逾時後重新執行同一個指令=繼續等待或拿回應,所以腳本要留到回應被讀走
|
|
222
|
+
name, hint = self._pending_button
|
|
223
|
+
scripts.setdefault(f"{name}.sh", self._script(name, hint))
|
|
224
|
+
self._sync(self._disk_files, self._files, None)
|
|
225
|
+
self._sync(self._disk_scripts, scripts, 0o755)
|
|
226
|
+
write_text(self.root / ".buttons.json", json.dumps(
|
|
227
|
+
{p: {"hint": h, "options": o} for p, (h, o) in self._buttons.items()},
|
|
228
|
+
ensure_ascii=False, indent=2) + "\n")
|
|
229
|
+
|
|
230
|
+
def _sync(self, disk: dict[str, str], want: dict[str, str], mode: int | None) -> None:
|
|
231
|
+
for path in set(disk) - set(want):
|
|
232
|
+
(self.root / path).unlink(missing_ok=True)
|
|
233
|
+
self._prune(self.root / path)
|
|
234
|
+
del disk[path]
|
|
235
|
+
for path, content in want.items():
|
|
236
|
+
if disk.get(path) != content:
|
|
237
|
+
target = self.root / path
|
|
238
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
write_text(target, content, mode)
|
|
240
|
+
disk[path] = content
|
|
241
|
+
|
|
242
|
+
def _prune(self, path: Path) -> None:
|
|
243
|
+
for d in path.parents:
|
|
244
|
+
if d == self.root:
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
d.rmdir()
|
|
248
|
+
except OSError:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
def _script(self, name: str, hint: str) -> str:
|
|
252
|
+
hint = " ".join(hint.split())
|
|
253
|
+
return SCRIPT.format(name=name, hint=f" — {hint}" if hint else "",
|
|
254
|
+
root=shlex.quote(str(self.root)), qname=shlex.quote(name))
|
|
255
|
+
|
|
256
|
+
def _call(self, fn, *args) -> None:
|
|
257
|
+
try:
|
|
258
|
+
fn(*args)
|
|
259
|
+
except Exception:
|
|
260
|
+
print(f"[folderui:{self.root.name}] {fn.__name__}() 發生錯誤:", file=sys.stderr)
|
|
261
|
+
traceback.print_exc()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: folderui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a folder into an immediate-mode GUI for humans and AI agents: *.md files are the view, *.sh files are buttons.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xiao-e-yun/folderui
|
|
6
|
+
Project-URL: Repository, https://github.com/xiao-e-yun/folderui
|
|
7
|
+
Author: xiao-e-yun
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,gui,immediate-mode,llm,multiplayer,turn-based
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# folderui
|
|
21
|
+
|
|
22
|
+
把**資料夾變成立即式 GUI**,給人類和 AI agent 使用。資料夾裡只有兩種東西:
|
|
23
|
+
|
|
24
|
+
- `*.md` 等檔案:畫面(隨時可以 `cat`)
|
|
25
|
+
- `*.sh`:按鈕(只有現在能按的才會出現)
|
|
26
|
+
|
|
27
|
+
執行按鈕後,腳本會**一直等到你需要做下一個決定**才回應,並印出:操作結果 + 這段期間發生的事 + 目前狀況 + 可以按的按鈕。
|
|
28
|
+
所以 agent 的迴圈就是「執行按鈕 → 讀回應 → 執行下一個按鈕」,不需要另外等待或輪詢。
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install folderui # 或 uv add folderui;只用標準函式庫,需要 POSIX shell
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ ./bid.sh 40
|
|
36
|
+
⏳ 已送出,等待中… ← 其他人還在出價,腳本卡住
|
|
37
|
+
✅ 已出價 40
|
|
38
|
+
開標:bob 5、carol 15、你 40
|
|
39
|
+
🔨 你以 40 得標 …
|
|
40
|
+
🔔 bob 拍賣「月光琉璃盞」…
|
|
41
|
+
|
|
42
|
+
── 可以做的事 ──
|
|
43
|
+
./bid.sh <金額 1-60> 密封出價
|
|
44
|
+
./pass.sh 不出價
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 寫一個 UI
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from folderui import FolderUI
|
|
51
|
+
|
|
52
|
+
class Guess(FolderUI):
|
|
53
|
+
def draw(self): # 每一幀重新宣告畫面,不可修改狀態
|
|
54
|
+
self.file("rules.md", "猜 1-100")
|
|
55
|
+
self.button("guess", "<數字>")
|
|
56
|
+
self.reply(f"已經猜了 {len(self.tries)} 次")
|
|
57
|
+
|
|
58
|
+
def action(self, name, args): # 按鈕被按下:回傳文字,或 raise ValueError 代表失敗
|
|
59
|
+
n = int(args[0])
|
|
60
|
+
...
|
|
61
|
+
return "太大"
|
|
62
|
+
|
|
63
|
+
Guess("/tmp/my-ui").run()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| 方法 | 在哪裡呼叫 | 作用 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `file(path, content)` | `draw()` | 宣告一個檔案;沒宣告的會自動刪除 |
|
|
69
|
+
| `button(path, hint, options=None)` | `draw()` | 宣告一個按鈕 → `<path>.sh`,路徑可以分層(`players/2/kick`) |
|
|
70
|
+
| `reply(text)` | `draw()` | 腳本回應時印出的目前狀況 |
|
|
71
|
+
| `notify(text)` | `action()` / `receive()` | 累積一則消息,下一次回應時送出 |
|
|
72
|
+
| `close()` | `action()` / `receive()` | 結束:移除按鈕、回應等待中的腳本 |
|
|
73
|
+
| `push(msg)` | 其他執行緒 | 送訊息給這個 UI,會呼叫 `receive(msg)` |
|
|
74
|
+
|
|
75
|
+
**回應規則只有一條**:操作處理完之後,第一個「有按鈕可按」的畫面出現時(或 `close()`)才回應。
|
|
76
|
+
|
|
77
|
+
- 輸入錯誤 → 狀態沒變、按鈕還在 → 立刻回應(`raise` 的訊息,exit code 1)
|
|
78
|
+
- 查詢類操作 → 按鈕還在 → 立刻回應
|
|
79
|
+
- 送出後要等別人 → 沒有按鈕 → 等到輪到你
|
|
80
|
+
|
|
81
|
+
## 多人:1 個伺服器 + N 個客戶端
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
class MyServer(Server):
|
|
85
|
+
def handle(self, who, cmd): # 純邏輯;raise ValueError 代表規則不允許
|
|
86
|
+
...
|
|
87
|
+
for p in self.clients:
|
|
88
|
+
self.push(p, Update(...)) # 推播給客戶端
|
|
89
|
+
return "✅ 已出價"
|
|
90
|
+
|
|
91
|
+
class PlayerUI(FolderUI):
|
|
92
|
+
def action(self, name, args):
|
|
93
|
+
return self.server.call(self.me, ("bid", int(args[0]))) # 同步呼叫,例外會傳回來
|
|
94
|
+
def receive(self, update):
|
|
95
|
+
self.view = update.view # 伺服器推播 → 框架自動重畫
|
|
96
|
+
|
|
97
|
+
server = MyServer()
|
|
98
|
+
for i in range(n):
|
|
99
|
+
server.connect(i, PlayerUI(f"{root}/player_{i}", server, i))
|
|
100
|
+
server.start()
|
|
101
|
+
for ui in server.clients.values():
|
|
102
|
+
ui.start()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
框架不管多人同步:伺服器在一個執行緒依序處理請求,推播走無上限佇列(伺服器永遠不等客戶端)。
|
|
106
|
+
|
|
107
|
+
## 程式型 agent
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from folderui import Session
|
|
111
|
+
s = Session("/tmp/folderui-auction-xxxx/player_0")
|
|
112
|
+
s.ready()
|
|
113
|
+
while buttons := s.buttons(): # {路徑: {hint, options}},空的代表結束
|
|
114
|
+
ok, text = s.press("bid", "40")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 細節
|
|
118
|
+
|
|
119
|
+
- 腳本預設無限等待。設定 `FOLDERUI_TIMEOUT=秒數` 會在逾時後以 exit code 2 結束;
|
|
120
|
+
**再執行一次同樣的指令即可繼續等待或拿到回應**(不會重複送出)。回應被讀走之前,該按鈕的腳本會保留。
|
|
121
|
+
- 協定檔案都是隱藏檔:`.request`、`.response`、`.done`、`.buttons.json`。
|
|
122
|
+
不能執行腳本的 agent 也可以直接寫 `.request`(按鈕路徑與參數以 NUL 分隔)。
|
|
123
|
+
|
|
124
|
+
## 範例
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
uv run python examples/guess.py # 單人:猜數字
|
|
128
|
+
uv run python examples/auction.py --players 4 --rounds 3 # 多人:英式拍賣(玩家 + 觀眾)
|
|
129
|
+
uv run python examples/auction_bot.py <資料夾> bot0 # 拍賣用的隨機 bot
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 開發
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv sync
|
|
136
|
+
uv run pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
發布:在 PyPI 設定 trusted publisher(repo `xiao-e-yun/folderui`、workflow `publish.yml`、environment `pypi`),
|
|
140
|
+
之後改 `pyproject.toml` 的版本號並推送 `v*` tag 就會自動發布。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
folderui/__init__.py,sha256=DgcWogffdAcswuQ4xnVbsra6JwV1PVDGyhHiTo6W9V4,206
|
|
2
|
+
folderui/fsutil.py,sha256=ddo13cW3NdwZQSr9y2vppC1xVnPlB1QVgzrKplS6Nog,799
|
|
3
|
+
folderui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
folderui/server.py,sha256=_gEhObn4GtDpl5AJQHkbCEhm07MyAsW8HpZUNFoQNNk,2130
|
|
5
|
+
folderui/session.py,sha256=U-c0Pi1YAh6_eSXONoUPNsaO4UIi0FxvT2ORzN6dgHA,1943
|
|
6
|
+
folderui/ui.py,sha256=hGJ2BLCdTYqdd0bDFCL7L2J4AJnVBvTilpTR2T17uSU,9723
|
|
7
|
+
folderui-0.1.0.dist-info/METADATA,sha256=lH5hDlDZU3QlQQD3fdWydJ9kwy5O-zCez1HuFR3aW1c,5361
|
|
8
|
+
folderui-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
folderui-0.1.0.dist-info/licenses/LICENSE,sha256=tcJlcDzyGAbI59TkI8E2ePZroQpo4WJyXpSq_Bqa-PI,1067
|
|
10
|
+
folderui-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xiao-e-yun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|