pocket-coding 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.
- pocket_coding-0.1.0.dist-info/METADATA +168 -0
- pocket_coding-0.1.0.dist-info/RECORD +7 -0
- pocket_coding-0.1.0.dist-info/WHEEL +4 -0
- pocket_coding-0.1.0.dist-info/entry_points.txt +2 -0
- poco/__init__.py +4 -0
- poco/app.py +577 -0
- poco/bridge.py +1221 -0
poco/app.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
17
|
+
from textual.widgets import Button, Checkbox, ContentSwitcher, Footer, Header, Input, Label, Log, Static
|
|
18
|
+
|
|
19
|
+
from .bridge import AppConfig, BridgeApp
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
LOG = logging.getLogger("poco")
|
|
23
|
+
CONFIG_DIR = Path.home() / ".config" / "poco"
|
|
24
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
25
|
+
STATE_DIR = Path.home() / ".local" / "state" / "poco"
|
|
26
|
+
THREAD_STATE_PATH = STATE_DIR / "threads.json"
|
|
27
|
+
WORKER_STATE_PATH = STATE_DIR / "workers.json"
|
|
28
|
+
|
|
29
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
30
|
+
"feishu": {
|
|
31
|
+
"app_id": "",
|
|
32
|
+
"app_secret": "",
|
|
33
|
+
"encrypt_key": "",
|
|
34
|
+
"verification_token": "",
|
|
35
|
+
"allowed_open_ids": [],
|
|
36
|
+
"allow_all_users": False,
|
|
37
|
+
},
|
|
38
|
+
"codex": {
|
|
39
|
+
"bin": "codex",
|
|
40
|
+
"app_server_args": "",
|
|
41
|
+
"model": "",
|
|
42
|
+
"approval_policy": "never",
|
|
43
|
+
"sandbox": "danger-full-access",
|
|
44
|
+
},
|
|
45
|
+
"bridge": {
|
|
46
|
+
"message_limit": 1400,
|
|
47
|
+
"live_update_initial_seconds": 1,
|
|
48
|
+
"live_update_max_seconds": 8,
|
|
49
|
+
"max_message_edits": 18,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
INPUT_IDS = {
|
|
54
|
+
"feishu.app_id": "feishu_app_id",
|
|
55
|
+
"feishu.app_secret": "feishu_app_secret",
|
|
56
|
+
"feishu.encrypt_key": "feishu_encrypt_key",
|
|
57
|
+
"feishu.verification_token": "feishu_verification_token",
|
|
58
|
+
"feishu.allowed_open_ids": "feishu_allowed_open_ids",
|
|
59
|
+
"codex.bin": "codex_bin",
|
|
60
|
+
"codex.app_server_args": "codex_app_server_args",
|
|
61
|
+
"codex.model": "codex_model",
|
|
62
|
+
"codex.approval_policy": "codex_approval_policy",
|
|
63
|
+
"codex.sandbox": "codex_sandbox",
|
|
64
|
+
"bridge.message_limit": "bridge_message_limit",
|
|
65
|
+
"bridge.live_update_initial_seconds": "bridge_live_update_initial_seconds",
|
|
66
|
+
"bridge.live_update_max_seconds": "bridge_live_update_max_seconds",
|
|
67
|
+
"bridge.max_message_edits": "bridge_max_message_edits",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def deep_merge(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
|
|
72
|
+
merged = deepcopy(base)
|
|
73
|
+
for key, value in patch.items():
|
|
74
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
75
|
+
merged[key] = deep_merge(merged[key], value)
|
|
76
|
+
else:
|
|
77
|
+
merged[key] = value
|
|
78
|
+
return merged
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mask_secret(value: str) -> str:
|
|
82
|
+
if len(value) <= 6:
|
|
83
|
+
return "*" * len(value)
|
|
84
|
+
return value[:3] + "*" * (len(value) - 6) + value[-3:]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def config_ready(config: Dict[str, Any]) -> bool:
|
|
88
|
+
feishu = config["feishu"]
|
|
89
|
+
return bool(feishu["app_id"] and feishu["app_secret"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_dirs() -> None:
|
|
93
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def set_nested(config: Dict[str, Any], path: str, value: Any) -> Dict[str, Any]:
|
|
98
|
+
parts = path.split(".")
|
|
99
|
+
current = config
|
|
100
|
+
for part in parts[:-1]:
|
|
101
|
+
current = current.setdefault(part, {})
|
|
102
|
+
current[parts[-1]] = value
|
|
103
|
+
return config
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ConfigStore:
|
|
107
|
+
def __init__(self, path: Path) -> None:
|
|
108
|
+
self._path = path
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
def load(self) -> Dict[str, Any]:
|
|
112
|
+
with self._lock:
|
|
113
|
+
if not self._path.exists():
|
|
114
|
+
return deepcopy(DEFAULT_CONFIG)
|
|
115
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
116
|
+
return deep_merge(DEFAULT_CONFIG, raw)
|
|
117
|
+
|
|
118
|
+
def save(self, config: Dict[str, Any]) -> None:
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
self._path.write_text(
|
|
122
|
+
json.dumps(config, ensure_ascii=False, indent=2),
|
|
123
|
+
encoding="utf-8",
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(self._path, 0o600)
|
|
127
|
+
except Exception:
|
|
128
|
+
LOG.exception("Failed to chmod config file")
|
|
129
|
+
|
|
130
|
+
def masked(self) -> Dict[str, Any]:
|
|
131
|
+
config = self.load()
|
|
132
|
+
masked = deepcopy(config)
|
|
133
|
+
if masked["feishu"]["app_secret"]:
|
|
134
|
+
masked["feishu"]["app_secret"] = mask_secret(masked["feishu"]["app_secret"])
|
|
135
|
+
return masked
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RingLogHandler(logging.Handler):
|
|
139
|
+
def __init__(self, limit: int = 500) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self._records = deque(maxlen=limit)
|
|
142
|
+
self._lock = threading.Lock()
|
|
143
|
+
|
|
144
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
145
|
+
message = self.format(record)
|
|
146
|
+
payload = {
|
|
147
|
+
"time": int(record.created),
|
|
148
|
+
"level": record.levelname,
|
|
149
|
+
"logger": record.name,
|
|
150
|
+
"message": message,
|
|
151
|
+
}
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._records.append(payload)
|
|
154
|
+
|
|
155
|
+
def snapshot(self, limit: int = 500) -> list[dict]:
|
|
156
|
+
with self._lock:
|
|
157
|
+
return list(self._records)[-limit:]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class BridgeRunner:
|
|
161
|
+
def __init__(self) -> None:
|
|
162
|
+
self._thread: Optional[threading.Thread] = None
|
|
163
|
+
self._last_error: str = ""
|
|
164
|
+
self._started_at: Optional[float] = None
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
def status(self) -> Dict[str, Any]:
|
|
168
|
+
with self._lock:
|
|
169
|
+
return {
|
|
170
|
+
"running": self._thread is not None and self._thread.is_alive(),
|
|
171
|
+
"started_at": self._started_at,
|
|
172
|
+
"last_error": self._last_error,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def start(self, config: Dict[str, Any]) -> bool:
|
|
176
|
+
with self._lock:
|
|
177
|
+
if self._thread is not None and self._thread.is_alive():
|
|
178
|
+
return False
|
|
179
|
+
self._last_error = ""
|
|
180
|
+
app_config = build_app_config(config)
|
|
181
|
+
thread = threading.Thread(
|
|
182
|
+
target=self._run_bridge,
|
|
183
|
+
args=(app_config,),
|
|
184
|
+
daemon=True,
|
|
185
|
+
name="poco-bridge",
|
|
186
|
+
)
|
|
187
|
+
self._thread = thread
|
|
188
|
+
self._started_at = time.time()
|
|
189
|
+
thread.start()
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
def _run_bridge(self, config: AppConfig) -> None:
|
|
193
|
+
try:
|
|
194
|
+
bridge = BridgeApp(config)
|
|
195
|
+
bridge.run()
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._last_error = str(exc)
|
|
199
|
+
LOG.exception("Bridge runtime crashed")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_app_config(config: Dict[str, Any]) -> AppConfig:
|
|
203
|
+
feishu = config["feishu"]
|
|
204
|
+
codex = config["codex"]
|
|
205
|
+
bridge = config["bridge"]
|
|
206
|
+
return AppConfig(
|
|
207
|
+
feishu_app_id=feishu["app_id"],
|
|
208
|
+
feishu_app_secret=feishu["app_secret"],
|
|
209
|
+
feishu_encrypt_key=feishu["encrypt_key"],
|
|
210
|
+
feishu_verification_token=feishu["verification_token"],
|
|
211
|
+
codex_bin=codex["bin"],
|
|
212
|
+
codex_app_server_args=codex["app_server_args"],
|
|
213
|
+
codex_model=codex["model"],
|
|
214
|
+
codex_approval_policy=codex["approval_policy"],
|
|
215
|
+
codex_sandbox=codex["sandbox"],
|
|
216
|
+
message_limit=int(bridge["message_limit"]),
|
|
217
|
+
live_update_initial_seconds=int(bridge["live_update_initial_seconds"]),
|
|
218
|
+
live_update_max_seconds=int(bridge["live_update_max_seconds"]),
|
|
219
|
+
max_message_edits=int(bridge["max_message_edits"]),
|
|
220
|
+
allowed_open_ids=set(feishu["allowed_open_ids"]),
|
|
221
|
+
allow_all_users=bool(feishu["allow_all_users"]),
|
|
222
|
+
thread_state_path=str(THREAD_STATE_PATH),
|
|
223
|
+
worker_state_path=str(WORKER_STATE_PATH),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class PoCoService:
|
|
228
|
+
def __init__(self, store: ConfigStore, logs: RingLogHandler) -> None:
|
|
229
|
+
self.store = store
|
|
230
|
+
self.logs = logs
|
|
231
|
+
self.bridge = BridgeRunner()
|
|
232
|
+
|
|
233
|
+
def load_config(self) -> Dict[str, Any]:
|
|
234
|
+
return self.store.load()
|
|
235
|
+
|
|
236
|
+
def save_config(self, config: Dict[str, Any]) -> None:
|
|
237
|
+
self.store.save(config)
|
|
238
|
+
|
|
239
|
+
def masked_config(self) -> Dict[str, Any]:
|
|
240
|
+
return self.store.masked()
|
|
241
|
+
|
|
242
|
+
def bridge_status(self) -> Dict[str, Any]:
|
|
243
|
+
return self.bridge.status()
|
|
244
|
+
|
|
245
|
+
def start_bridge(self) -> bool:
|
|
246
|
+
config = self.load_config()
|
|
247
|
+
if not config_ready(config):
|
|
248
|
+
raise ValueError("配置还不完整,至少需要 Feishu App ID 和 App Secret。")
|
|
249
|
+
return self.bridge.start(config)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class PoCoTui(App[None]):
|
|
253
|
+
CSS = """
|
|
254
|
+
Screen {
|
|
255
|
+
layout: vertical;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#main {
|
|
259
|
+
height: 1fr;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#actions {
|
|
263
|
+
height: auto;
|
|
264
|
+
padding: 0 1;
|
|
265
|
+
dock: top;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#summary-grid {
|
|
269
|
+
height: auto;
|
|
270
|
+
layout: grid;
|
|
271
|
+
grid-size: 2 4;
|
|
272
|
+
grid-gutter: 1 1;
|
|
273
|
+
padding: 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.panel {
|
|
277
|
+
border: round $primary;
|
|
278
|
+
padding: 0 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.field {
|
|
282
|
+
height: auto;
|
|
283
|
+
margin-bottom: 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.field Label {
|
|
287
|
+
padding-bottom: 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#config-scroll {
|
|
291
|
+
padding: 0 1 1 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#logs {
|
|
295
|
+
height: 1fr;
|
|
296
|
+
border: round $accent;
|
|
297
|
+
}
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
BINDINGS = [
|
|
301
|
+
Binding("q", "quit", "Quit"),
|
|
302
|
+
Binding("ctrl+s", "save_config", "Save"),
|
|
303
|
+
Binding("ctrl+r", "save_and_restart", "Save & Restart"),
|
|
304
|
+
Binding("f2", "show_dashboard", "Dashboard"),
|
|
305
|
+
Binding("f3", "show_config", "Config"),
|
|
306
|
+
Binding("f4", "show_logs", "Logs"),
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
def __init__(self, service: PoCoService, *, focus_config: bool = False) -> None:
|
|
310
|
+
super().__init__()
|
|
311
|
+
self._service = service
|
|
312
|
+
self._focus_config = focus_config
|
|
313
|
+
self._log_cursor = 0
|
|
314
|
+
|
|
315
|
+
def compose(self) -> ComposeResult:
|
|
316
|
+
yield Header(show_clock=True)
|
|
317
|
+
with Horizontal(id="actions"):
|
|
318
|
+
yield Button("Save & Restart", id="save_and_restart", variant="success")
|
|
319
|
+
yield Button("Save Config", id="save_config", variant="primary")
|
|
320
|
+
yield Button("Dashboard", id="open_dashboard")
|
|
321
|
+
yield Button("Config", id="open_config")
|
|
322
|
+
yield Button("Logs", id="open_logs")
|
|
323
|
+
yield Button("Quit", id="quit_app", variant="error")
|
|
324
|
+
with ContentSwitcher(initial="dashboard", id="main"):
|
|
325
|
+
with Vertical(id="dashboard"):
|
|
326
|
+
with Container(classes="panel"):
|
|
327
|
+
yield Static("", id="status_summary")
|
|
328
|
+
with Container(classes="panel"):
|
|
329
|
+
yield Static("", id="status_hint")
|
|
330
|
+
with Container(classes="panel"):
|
|
331
|
+
yield Static("", id="status_paths")
|
|
332
|
+
with Vertical(id="config"):
|
|
333
|
+
with VerticalScroll(id="config-scroll"):
|
|
334
|
+
yield self._section_label("Feishu")
|
|
335
|
+
yield self._field("App ID", Input(id=INPUT_IDS["feishu.app_id"], placeholder="cli_xxx"))
|
|
336
|
+
yield self._field(
|
|
337
|
+
"App Secret",
|
|
338
|
+
Input(id=INPUT_IDS["feishu.app_secret"], password=True, placeholder="app secret"),
|
|
339
|
+
)
|
|
340
|
+
yield self._field("Encrypt Key", Input(id=INPUT_IDS["feishu.encrypt_key"]))
|
|
341
|
+
yield self._field("Verification Token", Input(id=INPUT_IDS["feishu.verification_token"]))
|
|
342
|
+
yield self._field(
|
|
343
|
+
"Allow all users",
|
|
344
|
+
Checkbox("", id="feishu_allow_all_users"),
|
|
345
|
+
)
|
|
346
|
+
yield self._field(
|
|
347
|
+
"Allowed open_ids",
|
|
348
|
+
Input(id=INPUT_IDS["feishu.allowed_open_ids"], placeholder="ou_xxx,ou_yyy"),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
yield self._section_label("Codex")
|
|
352
|
+
yield self._field("Codex binary", Input(id=INPUT_IDS["codex.bin"], placeholder="codex"))
|
|
353
|
+
yield self._field("Model", Input(id=INPUT_IDS["codex.model"], placeholder="optional"))
|
|
354
|
+
yield self._field("App server args", Input(id=INPUT_IDS["codex.app_server_args"]))
|
|
355
|
+
yield self._field("Approval policy", Input(id=INPUT_IDS["codex.approval_policy"]))
|
|
356
|
+
yield self._field("Sandbox", Input(id=INPUT_IDS["codex.sandbox"]))
|
|
357
|
+
|
|
358
|
+
yield self._section_label("Bridge")
|
|
359
|
+
yield self._field("Message limit", Input(id=INPUT_IDS["bridge.message_limit"]))
|
|
360
|
+
yield self._field(
|
|
361
|
+
"Live update initial seconds",
|
|
362
|
+
Input(id=INPUT_IDS["bridge.live_update_initial_seconds"]),
|
|
363
|
+
)
|
|
364
|
+
yield self._field(
|
|
365
|
+
"Live update max seconds",
|
|
366
|
+
Input(id=INPUT_IDS["bridge.live_update_max_seconds"]),
|
|
367
|
+
)
|
|
368
|
+
yield self._field("Max message edits", Input(id=INPUT_IDS["bridge.max_message_edits"]))
|
|
369
|
+
with Vertical(id="logs-tab"):
|
|
370
|
+
yield Log(id="logs", auto_scroll=True, highlight=True)
|
|
371
|
+
yield Footer()
|
|
372
|
+
|
|
373
|
+
def _section_label(self, text: str) -> Static:
|
|
374
|
+
return Static(text, classes="panel")
|
|
375
|
+
|
|
376
|
+
def _field(self, label: str, widget: Input | Checkbox) -> Vertical:
|
|
377
|
+
return Vertical(Label(label), widget, classes="field")
|
|
378
|
+
|
|
379
|
+
def on_mount(self) -> None:
|
|
380
|
+
self.title = "PoCo"
|
|
381
|
+
self.sub_title = "Pocket Coding for Feishu"
|
|
382
|
+
self._load_form_from_config()
|
|
383
|
+
self._refresh_runtime()
|
|
384
|
+
self.set_interval(1.0, self._refresh_runtime)
|
|
385
|
+
self.call_after_refresh(self._boot)
|
|
386
|
+
|
|
387
|
+
def _boot(self) -> None:
|
|
388
|
+
config = self._service.load_config()
|
|
389
|
+
if self._focus_config or not config_ready(config):
|
|
390
|
+
self._activate_tab("config")
|
|
391
|
+
self.notify("需要先完成配置,再启动 bridge。", severity="warning")
|
|
392
|
+
self._focus_first_missing_required_field(config)
|
|
393
|
+
return
|
|
394
|
+
self._service.start_bridge()
|
|
395
|
+
self.notify("PoCo bridge 已启动。", severity="information")
|
|
396
|
+
|
|
397
|
+
def _refresh_runtime(self) -> None:
|
|
398
|
+
self._refresh_status()
|
|
399
|
+
self._refresh_logs()
|
|
400
|
+
|
|
401
|
+
def _refresh_status(self) -> None:
|
|
402
|
+
config = self._service.load_config()
|
|
403
|
+
bridge = self._service.bridge_status()
|
|
404
|
+
started_at = bridge["started_at"]
|
|
405
|
+
started_display = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(started_at)) if started_at else "-"
|
|
406
|
+
summary = (
|
|
407
|
+
f"config_ready: {config_ready(config)}\n"
|
|
408
|
+
f"bridge_running: {bridge['running']}\n"
|
|
409
|
+
f"bridge_started_at: {started_display}\n"
|
|
410
|
+
f"bridge_last_error: {bridge['last_error'] or '-'}"
|
|
411
|
+
)
|
|
412
|
+
hint = (
|
|
413
|
+
"F2 Dashboard F3 Config F4 Logs\n"
|
|
414
|
+
"Ctrl+S 保存配置 Ctrl+R 保存并重启\n"
|
|
415
|
+
"Save & Restart 会重启 PoCo 本身,并按新配置重新启动 bridge。"
|
|
416
|
+
)
|
|
417
|
+
paths = (
|
|
418
|
+
f"config_path: {CONFIG_PATH}\n"
|
|
419
|
+
f"thread_state_path: {THREAD_STATE_PATH}\n"
|
|
420
|
+
f"worker_state_path: {WORKER_STATE_PATH}"
|
|
421
|
+
)
|
|
422
|
+
self.query_one("#status_summary", Static).update(summary)
|
|
423
|
+
self.query_one("#status_hint", Static).update(hint)
|
|
424
|
+
self.query_one("#status_paths", Static).update(paths)
|
|
425
|
+
|
|
426
|
+
def _refresh_logs(self) -> None:
|
|
427
|
+
items = self._service.logs.snapshot(limit=500)
|
|
428
|
+
log_widget = self.query_one("#logs", Log)
|
|
429
|
+
if len(items) < self._log_cursor:
|
|
430
|
+
self._log_cursor = 0
|
|
431
|
+
log_widget.clear()
|
|
432
|
+
for item in items[self._log_cursor:]:
|
|
433
|
+
log_widget.write_line(item["message"])
|
|
434
|
+
self._log_cursor = len(items)
|
|
435
|
+
|
|
436
|
+
def _load_form_from_config(self) -> None:
|
|
437
|
+
config = self._service.load_config()
|
|
438
|
+
for path, widget_id in INPUT_IDS.items():
|
|
439
|
+
value = self._get_config_value(config, path)
|
|
440
|
+
text = ",".join(value) if isinstance(value, list) else str(value)
|
|
441
|
+
self.query_one(f"#{widget_id}", Input).value = text
|
|
442
|
+
allow_all = bool(config["feishu"]["allow_all_users"])
|
|
443
|
+
self.query_one("#feishu_allow_all_users", Checkbox).value = allow_all
|
|
444
|
+
self.query_one(f"#{INPUT_IDS['feishu.allowed_open_ids']}", Input).disabled = allow_all
|
|
445
|
+
|
|
446
|
+
def _config_from_form(self) -> Dict[str, Any]:
|
|
447
|
+
config = deepcopy(DEFAULT_CONFIG)
|
|
448
|
+
for path, widget_id in INPUT_IDS.items():
|
|
449
|
+
raw = self.query_one(f"#{widget_id}", Input).value.strip()
|
|
450
|
+
if path == "feishu.allowed_open_ids":
|
|
451
|
+
value = [item.strip() for item in raw.split(",") if item.strip()]
|
|
452
|
+
elif path.startswith("bridge."):
|
|
453
|
+
try:
|
|
454
|
+
value = int(raw)
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
raise ValueError(f"{path} 必须是整数。") from exc
|
|
457
|
+
else:
|
|
458
|
+
value = raw
|
|
459
|
+
set_nested(config, path, value)
|
|
460
|
+
config["feishu"]["allow_all_users"] = self.query_one("#feishu_allow_all_users", Checkbox).value
|
|
461
|
+
return deep_merge(self._service.load_config(), config)
|
|
462
|
+
|
|
463
|
+
def _get_config_value(self, config: Dict[str, Any], path: str) -> Any:
|
|
464
|
+
value: Any = config
|
|
465
|
+
for part in path.split("."):
|
|
466
|
+
value = value[part]
|
|
467
|
+
return value
|
|
468
|
+
|
|
469
|
+
def _focus_first_missing_required_field(self, config: Dict[str, Any]) -> None:
|
|
470
|
+
if not config["feishu"]["app_id"]:
|
|
471
|
+
self.query_one(f"#{INPUT_IDS['feishu.app_id']}", Input).focus()
|
|
472
|
+
return
|
|
473
|
+
if not config["feishu"]["app_secret"]:
|
|
474
|
+
self.query_one(f"#{INPUT_IDS['feishu.app_secret']}", Input).focus()
|
|
475
|
+
return
|
|
476
|
+
self.query_one(f"#{INPUT_IDS['codex.bin']}", Input).focus()
|
|
477
|
+
|
|
478
|
+
def _activate_tab(self, tab_id: str) -> None:
|
|
479
|
+
self.query_one(ContentSwitcher).current = tab_id
|
|
480
|
+
|
|
481
|
+
def action_save_config(self) -> None:
|
|
482
|
+
self._save_config()
|
|
483
|
+
|
|
484
|
+
def action_save_and_restart(self) -> None:
|
|
485
|
+
self._save_and_restart()
|
|
486
|
+
|
|
487
|
+
def action_show_dashboard(self) -> None:
|
|
488
|
+
self._activate_tab("dashboard")
|
|
489
|
+
|
|
490
|
+
def action_show_config(self) -> None:
|
|
491
|
+
self._activate_tab("config")
|
|
492
|
+
|
|
493
|
+
def action_show_logs(self) -> None:
|
|
494
|
+
self._activate_tab("logs-tab")
|
|
495
|
+
|
|
496
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
497
|
+
button_id = event.button.id
|
|
498
|
+
if button_id == "save_and_restart":
|
|
499
|
+
self._save_and_restart()
|
|
500
|
+
return
|
|
501
|
+
if button_id == "save_config":
|
|
502
|
+
self._save_config()
|
|
503
|
+
return
|
|
504
|
+
if button_id == "open_dashboard":
|
|
505
|
+
self._activate_tab("dashboard")
|
|
506
|
+
return
|
|
507
|
+
if button_id == "open_config":
|
|
508
|
+
self._activate_tab("config")
|
|
509
|
+
return
|
|
510
|
+
if button_id == "open_logs":
|
|
511
|
+
self._activate_tab("logs-tab")
|
|
512
|
+
return
|
|
513
|
+
if button_id == "quit_app":
|
|
514
|
+
self.exit()
|
|
515
|
+
|
|
516
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
517
|
+
if event.checkbox.id != "feishu_allow_all_users":
|
|
518
|
+
return
|
|
519
|
+
allowed = self.query_one(f"#{INPUT_IDS['feishu.allowed_open_ids']}", Input)
|
|
520
|
+
allowed.disabled = event.value
|
|
521
|
+
|
|
522
|
+
def _save_config(self) -> None:
|
|
523
|
+
try:
|
|
524
|
+
config = self._config_from_form()
|
|
525
|
+
except ValueError as exc:
|
|
526
|
+
self.notify(str(exc), severity="error")
|
|
527
|
+
return
|
|
528
|
+
self._service.save_config(config)
|
|
529
|
+
self._refresh_status()
|
|
530
|
+
self.notify("配置已保存。", severity="information")
|
|
531
|
+
|
|
532
|
+
def _save_and_restart(self) -> None:
|
|
533
|
+
try:
|
|
534
|
+
config = self._config_from_form()
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
self.notify(str(exc), severity="error")
|
|
537
|
+
return
|
|
538
|
+
self._service.save_config(config)
|
|
539
|
+
self.exit("restart")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parse_args() -> argparse.Namespace:
|
|
543
|
+
parser = argparse.ArgumentParser(description="PoCo")
|
|
544
|
+
parser.add_argument("--log-level", default="INFO")
|
|
545
|
+
sub = parser.add_subparsers(dest="command")
|
|
546
|
+
config_cmd = sub.add_parser("config", help="open PoCo and focus config tab")
|
|
547
|
+
config_cmd.add_argument("--show", action="store_true", help="show masked config and exit")
|
|
548
|
+
return parser.parse_args()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def main() -> None:
|
|
552
|
+
args = parse_args()
|
|
553
|
+
logging.basicConfig(
|
|
554
|
+
level=getattr(logging, str(getattr(args, "log_level", "INFO")).upper(), logging.INFO),
|
|
555
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
556
|
+
)
|
|
557
|
+
ensure_dirs()
|
|
558
|
+
root_logger = logging.getLogger()
|
|
559
|
+
ring = RingLogHandler()
|
|
560
|
+
ring.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
561
|
+
root_logger.addHandler(ring)
|
|
562
|
+
|
|
563
|
+
store = ConfigStore(CONFIG_PATH)
|
|
564
|
+
service = PoCoService(store, ring)
|
|
565
|
+
|
|
566
|
+
if args.command == "config" and args.show:
|
|
567
|
+
print(json.dumps(service.masked_config(), ensure_ascii=False, indent=2))
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
app = PoCoTui(service, focus_config=args.command == "config")
|
|
571
|
+
result = app.run()
|
|
572
|
+
if result == "restart":
|
|
573
|
+
os.execv(sys.executable, [sys.executable, *sys.argv])
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
main()
|