pocket-coding 0.2.0__tar.gz → 0.2.1__tar.gz
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.2.0 → pocket_coding-0.2.1}/PKG-INFO +1 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/__init__.py +1 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/app.py +31 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/config/__init__.py +2 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/config/store.py +18 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/claude.py +6 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/codex.py +1 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/app.py +33 -25
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/cards.py +38 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/messenger.py +28 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/runtime.py +101 -15
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/stores.py +107 -46
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/runtime.py +45 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/app.py +30 -4
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/resources.py +7 -2
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/pyproject.toml +1 -1
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/.gitignore +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/README.md +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/README.zh-CN.md +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/bootstrap/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/bootstrap/feishu.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/base.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/models.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/models.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/utils.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/__init__.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/claude.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/controller.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/render_base.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/render_forms.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/sections.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/types.py +0 -0
- {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/root.py +0 -0
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import fcntl
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
from logging.handlers import RotatingFileHandler
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
from .bootstrap import run_feishu_bootstrap_cli
|
|
9
11
|
from .runtime import (
|
|
10
12
|
PoCoService,
|
|
11
13
|
RingLogHandler,
|
|
12
14
|
)
|
|
13
|
-
from .config import CONFIG_PATH, LOG_PATH, ConfigStore, ensure_dirs, get_nested
|
|
15
|
+
from .config import CONFIG_PATH, LOG_PATH, STATE_DIR, ConfigStore, ensure_dirs, get_nested
|
|
14
16
|
from .tui import PoCoTui
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
_APP_LOCK = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _acquire_app_lock() -> bool:
|
|
23
|
+
"""Acquires a process-wide PoCo instance lock.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True when this process successfully acquired the lock, or False when
|
|
27
|
+
another PoCo process is already holding it.
|
|
28
|
+
"""
|
|
29
|
+
global _APP_LOCK
|
|
30
|
+
lock_path = Path(STATE_DIR) / "poco.lock"
|
|
31
|
+
handle = lock_path.open("w", encoding="utf-8")
|
|
32
|
+
try:
|
|
33
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
34
|
+
except BlockingIOError:
|
|
35
|
+
handle.close()
|
|
36
|
+
return False
|
|
37
|
+
handle.seek(0)
|
|
38
|
+
handle.truncate()
|
|
39
|
+
handle.write(str(os.getpid()))
|
|
40
|
+
handle.flush()
|
|
41
|
+
_APP_LOCK = handle
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
17
45
|
def parse_args() -> argparse.Namespace:
|
|
18
46
|
parser = argparse.ArgumentParser(description="PoCo")
|
|
19
47
|
parser.add_argument("--log-level", default="INFO")
|
|
@@ -33,6 +61,8 @@ def main() -> None:
|
|
|
33
61
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
34
62
|
)
|
|
35
63
|
ensure_dirs()
|
|
64
|
+
if not _acquire_app_lock():
|
|
65
|
+
raise SystemExit("Another PoCo process is already running. Kill the existing process first.")
|
|
36
66
|
root_logger = logging.getLogger()
|
|
37
67
|
ring = RingLogHandler()
|
|
38
68
|
ring.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
@@ -12,6 +12,7 @@ from .store import (
|
|
|
12
12
|
ensure_dirs,
|
|
13
13
|
get_nested,
|
|
14
14
|
mask_secret,
|
|
15
|
+
missing_required_config_paths,
|
|
15
16
|
normalize_config_key,
|
|
16
17
|
parse_config_value,
|
|
17
18
|
set_nested,
|
|
@@ -31,6 +32,7 @@ __all__ = [
|
|
|
31
32
|
"ensure_dirs",
|
|
32
33
|
"get_nested",
|
|
33
34
|
"mask_secret",
|
|
35
|
+
"missing_required_config_paths",
|
|
34
36
|
"normalize_config_key",
|
|
35
37
|
"parse_config_value",
|
|
36
38
|
"set_nested",
|
|
@@ -197,6 +197,24 @@ def config_ready(config: Dict[str, Any]) -> bool:
|
|
|
197
197
|
return bool(feishu["app_id"] and feishu["app_secret"])
|
|
198
198
|
|
|
199
199
|
|
|
200
|
+
def missing_required_config_paths(config: Dict[str, Any]) -> list[str]:
|
|
201
|
+
"""Returns the required config paths that are still missing.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
config: Current merged config payload.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
A list of dotted config paths that must be filled before PoCo can run.
|
|
208
|
+
"""
|
|
209
|
+
missing: list[str] = []
|
|
210
|
+
feishu = config.get("feishu", {})
|
|
211
|
+
if not str(feishu.get("app_id", "")).strip():
|
|
212
|
+
missing.append("feishu.app_id")
|
|
213
|
+
if not str(feishu.get("app_secret", "")).strip():
|
|
214
|
+
missing.append("feishu.app_secret")
|
|
215
|
+
return missing
|
|
216
|
+
|
|
217
|
+
|
|
200
218
|
def ensure_dirs() -> None:
|
|
201
219
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
202
220
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -185,7 +185,10 @@ class ClaudeProviderClient:
|
|
|
185
185
|
cmd.extend(["--session-id", thread_id])
|
|
186
186
|
if self._provider.model:
|
|
187
187
|
cmd.extend(["--model", self._provider.model])
|
|
188
|
-
|
|
188
|
+
approval_policy = self._provider.approval_policy.strip()
|
|
189
|
+
if approval_policy in {"bypassPermissions", "dangerously-skip-permissions"}:
|
|
190
|
+
cmd.append("--dangerously-skip-permissions")
|
|
191
|
+
elif approval_policy:
|
|
189
192
|
cmd.extend(["--permission-mode", self._provider.approval_policy])
|
|
190
193
|
image_paths = [path for path in (local_image_paths or []) if path]
|
|
191
194
|
for parent in sorted({str(Path(path).expanduser().resolve().parent) for path in image_paths}):
|
|
@@ -203,6 +206,8 @@ class ClaudeProviderClient:
|
|
|
203
206
|
cmd.append(text)
|
|
204
207
|
env = os.environ.copy()
|
|
205
208
|
env.update(self._provider.env)
|
|
209
|
+
if str(self._provider.sandbox).strip() in {"1", "true", "True", "sandbox", "is_sandbox"}:
|
|
210
|
+
env["IS_SANDBOX"] = "1"
|
|
206
211
|
process = subprocess.Popen(
|
|
207
212
|
cmd,
|
|
208
213
|
stdin=subprocess.DEVNULL,
|
|
@@ -216,7 +216,7 @@ class CodexProviderClient:
|
|
|
216
216
|
self._request(
|
|
217
217
|
"initialize",
|
|
218
218
|
{
|
|
219
|
-
"clientInfo": {"name": "poco-provider-bridge", "version": "0.2.
|
|
219
|
+
"clientInfo": {"name": "poco-provider-bridge", "version": "0.2.1"},
|
|
220
220
|
"capabilities": {"experimentalApi": True},
|
|
221
221
|
},
|
|
222
222
|
)
|
|
@@ -435,14 +435,9 @@ class RelayApp:
|
|
|
435
435
|
self._safe_send(chat_id, "[poco] 单聊是管理控制台,暂不处理图片。请在项目群里发送图片。")
|
|
436
436
|
return
|
|
437
437
|
worker_id = chat_id
|
|
438
|
-
self.
|
|
439
|
-
if
|
|
440
|
-
self._safe_send(
|
|
441
|
-
chat_id,
|
|
442
|
-
"[poco] This group is not active yet.\n"
|
|
443
|
-
"Create and manage project groups from the DM console instead of configuring them here.\n"
|
|
444
|
-
"DM the bot with `poco`.",
|
|
445
|
-
)
|
|
438
|
+
state_error = self._group_access_error_text(worker_id)
|
|
439
|
+
if state_error:
|
|
440
|
+
self._safe_send(chat_id, state_error)
|
|
446
441
|
return
|
|
447
442
|
self._safe_send(
|
|
448
443
|
chat_id,
|
|
@@ -468,14 +463,9 @@ class RelayApp:
|
|
|
468
463
|
return
|
|
469
464
|
|
|
470
465
|
worker_id = chat_id
|
|
471
|
-
self.
|
|
472
|
-
if
|
|
473
|
-
self._safe_send(
|
|
474
|
-
chat_id,
|
|
475
|
-
"[poco] This group is not active yet.\n"
|
|
476
|
-
"Create and manage project groups from the DM console instead.\n"
|
|
477
|
-
"DM the bot with `poco`.",
|
|
478
|
-
)
|
|
466
|
+
state_error = self._group_access_error_text(worker_id)
|
|
467
|
+
if state_error:
|
|
468
|
+
self._safe_send(chat_id, state_error)
|
|
479
469
|
return
|
|
480
470
|
|
|
481
471
|
local_image_paths = [
|
|
@@ -510,7 +500,6 @@ class RelayApp:
|
|
|
510
500
|
open_id: str,
|
|
511
501
|
) -> None:
|
|
512
502
|
worker_id = chat_id
|
|
513
|
-
self._worker_store.ensure_worker(worker_id)
|
|
514
503
|
command, arg = self._parse_poco_command(text)
|
|
515
504
|
stripped = text.strip().lower()
|
|
516
505
|
if command == "cardtest":
|
|
@@ -525,13 +514,9 @@ class RelayApp:
|
|
|
525
514
|
"请去单聊发送 `poco`,这个群里直接聊天即可。",
|
|
526
515
|
)
|
|
527
516
|
return
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
"[poco] This group is not active yet.\n"
|
|
532
|
-
"Create and manage project groups from the DM console.\n"
|
|
533
|
-
"DM the bot with `poco`.",
|
|
534
|
-
)
|
|
517
|
+
state_error = self._group_access_error_text(worker_id)
|
|
518
|
+
if state_error:
|
|
519
|
+
self._safe_send(chat_id, state_error)
|
|
535
520
|
return
|
|
536
521
|
|
|
537
522
|
self._handle_group_turn_input(worker_id, chat_id, chat_type, text, mentions, open_id)
|
|
@@ -720,8 +705,31 @@ class RelayApp:
|
|
|
720
705
|
cleaned = cleaned.replace(f"@{name}", " ")
|
|
721
706
|
return " ".join(cleaned.split())
|
|
722
707
|
|
|
708
|
+
def _group_access_error_text(self, worker_id: str) -> Optional[str]:
|
|
709
|
+
if not self._worker_store.has_worker(worker_id):
|
|
710
|
+
if self._store.get(worker_id):
|
|
711
|
+
return (
|
|
712
|
+
"[poco] This group's local project state is missing or corrupted.\n"
|
|
713
|
+
"Please go back to the DM console and recreate or repair this project group."
|
|
714
|
+
)
|
|
715
|
+
return (
|
|
716
|
+
"[poco] This group is not active yet.\n"
|
|
717
|
+
"Create and manage project groups from the DM console.\n"
|
|
718
|
+
"DM the bot with `poco`."
|
|
719
|
+
)
|
|
720
|
+
if not self._worker_store.enabled_for(worker_id):
|
|
721
|
+
return (
|
|
722
|
+
"[poco] This group is not active yet.\n"
|
|
723
|
+
"Create and manage project groups from the DM console.\n"
|
|
724
|
+
"DM the bot with `poco`."
|
|
725
|
+
)
|
|
726
|
+
return None
|
|
727
|
+
|
|
723
728
|
def _ensure_worker(self, worker_id: str, chat_id: str, chat_type: str) -> WorkerRuntime:
|
|
724
|
-
self._worker_store.
|
|
729
|
+
if not self._worker_store.has_worker(worker_id):
|
|
730
|
+
raise RuntimeError(
|
|
731
|
+
"当前群的本地项目状态缺失或损坏。请回到 DM 控制台重新创建或修复这个项目群。"
|
|
732
|
+
)
|
|
725
733
|
cwd = self._worker_store.cwd_for(worker_id)
|
|
726
734
|
provider_name = self._provider_name_for_worker(worker_id)
|
|
727
735
|
if not provider_name:
|
|
@@ -861,6 +861,17 @@ class SetupCardController:
|
|
|
861
861
|
payload["toast"] = {"type": toast_type, "content": notice}
|
|
862
862
|
return P2CardActionTriggerResponse(payload)
|
|
863
863
|
|
|
864
|
+
@staticmethod
|
|
865
|
+
def response_toast(notice: str, toast_type: str = "info") -> P2CardActionTriggerResponse:
|
|
866
|
+
return P2CardActionTriggerResponse(
|
|
867
|
+
{
|
|
868
|
+
"toast": {
|
|
869
|
+
"type": toast_type,
|
|
870
|
+
"content": notice,
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
)
|
|
874
|
+
|
|
864
875
|
@staticmethod
|
|
865
876
|
def coerce_form_value(value: object) -> str:
|
|
866
877
|
if value is None:
|
|
@@ -1086,6 +1097,33 @@ class SetupCardController:
|
|
|
1086
1097
|
"success",
|
|
1087
1098
|
)
|
|
1088
1099
|
|
|
1100
|
+
if action_name == "stop_turn":
|
|
1101
|
+
with self.app._lock:
|
|
1102
|
+
session = self.app._active_by_worker.get(worker_id)
|
|
1103
|
+
worker = self.app._workers.get(worker_id)
|
|
1104
|
+
if session is None or worker is None:
|
|
1105
|
+
return self.response_toast("Nothing to stop.", "info")
|
|
1106
|
+
try:
|
|
1107
|
+
worker.client.interrupt_turn(session.thread_id, session.turn_id)
|
|
1108
|
+
except Exception as exc:
|
|
1109
|
+
return self.response_toast(f"Stop failed: {exc}", "error")
|
|
1110
|
+
with session.lock:
|
|
1111
|
+
rendered = (session.final_text or session.accumulated_text).rstrip() or "_Stopped._"
|
|
1112
|
+
session.done = True
|
|
1113
|
+
session.final_text = rendered
|
|
1114
|
+
session.error_text = ""
|
|
1115
|
+
session.live.card_broken = True
|
|
1116
|
+
session.notify()
|
|
1117
|
+
return self.response_card(
|
|
1118
|
+
self.app._turns.answer_card(
|
|
1119
|
+
worker.provider_name,
|
|
1120
|
+
rendered,
|
|
1121
|
+
state="stopped",
|
|
1122
|
+
stop_enabled=False,
|
|
1123
|
+
streaming=False,
|
|
1124
|
+
),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1089
1127
|
if action_name in {"launch_project", "reset_project_launch"}:
|
|
1090
1128
|
def project_card_response(
|
|
1091
1129
|
notice: str = "",
|
|
@@ -11,10 +11,13 @@ from typing import Dict, Optional
|
|
|
11
11
|
import lark_oapi as lark
|
|
12
12
|
from lark_oapi.api.cardkit.v1.model.content_card_element_request import ContentCardElementRequest
|
|
13
13
|
from lark_oapi.api.cardkit.v1.model.content_card_element_request_body import ContentCardElementRequestBody
|
|
14
|
+
from lark_oapi.api.cardkit.v1.model.card import Card
|
|
14
15
|
from lark_oapi.api.cardkit.v1.model.create_card_request import CreateCardRequest
|
|
15
16
|
from lark_oapi.api.cardkit.v1.model.create_card_request_body import CreateCardRequestBody
|
|
16
17
|
from lark_oapi.api.cardkit.v1.model.settings_card_request import SettingsCardRequest
|
|
17
18
|
from lark_oapi.api.cardkit.v1.model.settings_card_request_body import SettingsCardRequestBody
|
|
19
|
+
from lark_oapi.api.cardkit.v1.model.update_card_request import UpdateCardRequest
|
|
20
|
+
from lark_oapi.api.cardkit.v1.model.update_card_request_body import UpdateCardRequestBody
|
|
18
21
|
from lark_oapi.api.im.v1 import (
|
|
19
22
|
CreateChatMembersRequest,
|
|
20
23
|
CreateChatMembersRequestBody,
|
|
@@ -383,6 +386,31 @@ class FeishuMessenger:
|
|
|
383
386
|
f"log_id={response.get_log_id()}"
|
|
384
387
|
)
|
|
385
388
|
|
|
389
|
+
def update_card_entity(self, card_id: str, card: dict, sequence: int) -> None:
|
|
390
|
+
request = (
|
|
391
|
+
UpdateCardRequest.builder()
|
|
392
|
+
.card_id(card_id)
|
|
393
|
+
.request_body(
|
|
394
|
+
UpdateCardRequestBody.builder()
|
|
395
|
+
.uuid(str(uuid.uuid4()))
|
|
396
|
+
.sequence(sequence)
|
|
397
|
+
.card(
|
|
398
|
+
Card.builder()
|
|
399
|
+
.type("card_json")
|
|
400
|
+
.data(json.dumps(card, ensure_ascii=False, separators=(",", ":")))
|
|
401
|
+
.build()
|
|
402
|
+
)
|
|
403
|
+
.build()
|
|
404
|
+
)
|
|
405
|
+
.build()
|
|
406
|
+
)
|
|
407
|
+
response = self._client.cardkit.v1.card.update(request)
|
|
408
|
+
if response.code != 0:
|
|
409
|
+
raise RuntimeError(
|
|
410
|
+
f"Feishu update card entity failed: code={response.code}, msg={response.msg}, "
|
|
411
|
+
f"log_id={response.get_log_id()}"
|
|
412
|
+
)
|
|
413
|
+
|
|
386
414
|
def update_text(self, message_id: str, text: str) -> None:
|
|
387
415
|
request = (
|
|
388
416
|
UpdateMessageRequest.builder()
|
|
@@ -175,6 +175,19 @@ class TurnController:
|
|
|
175
175
|
)
|
|
176
176
|
live.stream_sequence += 1
|
|
177
177
|
live.streaming_mode = False
|
|
178
|
+
self.app._messenger.update_card_entity(
|
|
179
|
+
live.card_id,
|
|
180
|
+
self.answer_card(
|
|
181
|
+
self.app._provider_name_for_worker(session.worker_id),
|
|
182
|
+
rendered,
|
|
183
|
+
state="completed",
|
|
184
|
+
stop_enabled=False,
|
|
185
|
+
streaming=False,
|
|
186
|
+
element_id=live.element_id or "answer_stream",
|
|
187
|
+
),
|
|
188
|
+
live.stream_sequence + 1,
|
|
189
|
+
)
|
|
190
|
+
live.stream_sequence += 1
|
|
178
191
|
except Exception:
|
|
179
192
|
LOG.exception("Failed to close streaming mode for card_id=%s", live.card_id)
|
|
180
193
|
return
|
|
@@ -301,38 +314,111 @@ class TurnController:
|
|
|
301
314
|
streaming_mode=True,
|
|
302
315
|
)
|
|
303
316
|
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
317
|
+
def answer_card(
|
|
318
|
+
self,
|
|
319
|
+
provider_name: str,
|
|
320
|
+
text: str,
|
|
321
|
+
*,
|
|
322
|
+
state: str,
|
|
323
|
+
stop_enabled: bool,
|
|
324
|
+
streaming: bool,
|
|
325
|
+
element_id: str = "answer_stream",
|
|
326
|
+
) -> dict:
|
|
327
|
+
"""Build one reply card for streaming, completed, or stopped states."""
|
|
328
|
+
normalized = state.strip().lower()
|
|
329
|
+
if normalized == "stopped":
|
|
330
|
+
template = "orange"
|
|
331
|
+
tag_text = "Stopped"
|
|
332
|
+
tag_color = "orange"
|
|
333
|
+
elif normalized == "completed":
|
|
334
|
+
template = "green"
|
|
335
|
+
tag_text = "Completed"
|
|
336
|
+
tag_color = "green"
|
|
337
|
+
else:
|
|
338
|
+
template = "orange"
|
|
339
|
+
tag_text = "Running"
|
|
340
|
+
tag_color = "blue"
|
|
341
|
+
elements = [
|
|
342
|
+
{
|
|
343
|
+
"tag": "markdown",
|
|
344
|
+
"element_id": element_id,
|
|
345
|
+
"content": text,
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
if stop_enabled:
|
|
349
|
+
elements.append(
|
|
350
|
+
{
|
|
351
|
+
"tag": "column_set",
|
|
352
|
+
"horizontal_spacing": "8px",
|
|
353
|
+
"columns": [
|
|
354
|
+
{
|
|
355
|
+
"tag": "column",
|
|
356
|
+
"width": "auto",
|
|
357
|
+
"elements": [
|
|
358
|
+
self.app._card_button("Stop", "stop_turn", "default")
|
|
359
|
+
],
|
|
360
|
+
}
|
|
361
|
+
],
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
elif normalized == "stopped":
|
|
365
|
+
elements.append(
|
|
366
|
+
{
|
|
367
|
+
"tag": "column_set",
|
|
368
|
+
"horizontal_spacing": "8px",
|
|
369
|
+
"columns": [
|
|
370
|
+
{
|
|
371
|
+
"tag": "column",
|
|
372
|
+
"width": "auto",
|
|
373
|
+
"elements": [
|
|
374
|
+
self.app._card_button("Stopped", "stopped", "default", enable_callback=False)
|
|
375
|
+
],
|
|
376
|
+
}
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
return {
|
|
308
381
|
"schema": "2.0",
|
|
309
382
|
"config": {
|
|
310
383
|
"update_multi": True,
|
|
311
384
|
"enable_forward": True,
|
|
312
|
-
"streaming_mode":
|
|
313
|
-
"summary": {"content": "Generating..."},
|
|
385
|
+
"streaming_mode": streaming,
|
|
386
|
+
"summary": {"content": "Generating..." if streaming else shorten(text, 80)},
|
|
314
387
|
"streaming_config": {
|
|
315
388
|
"print_frequency_ms": {"default": 70},
|
|
316
389
|
"print_step": {"default": 1},
|
|
317
390
|
"print_strategy": "fast",
|
|
318
|
-
},
|
|
391
|
+
} if streaming else {},
|
|
319
392
|
},
|
|
320
393
|
"header": {
|
|
321
|
-
"template":
|
|
394
|
+
"template": template,
|
|
322
395
|
"title": {"tag": "plain_text", "content": f"{provider_name.capitalize()} Reply"},
|
|
396
|
+
"text_tag_list": [
|
|
397
|
+
{
|
|
398
|
+
"tag": "text_tag",
|
|
399
|
+
"text": {"tag": "plain_text", "content": tag_text},
|
|
400
|
+
"color": tag_color,
|
|
401
|
+
}
|
|
402
|
+
],
|
|
323
403
|
},
|
|
324
404
|
"body": {
|
|
325
405
|
"padding": "14px",
|
|
326
406
|
"vertical_spacing": "8px",
|
|
327
|
-
"elements":
|
|
328
|
-
{
|
|
329
|
-
"tag": "markdown",
|
|
330
|
-
"element_id": element_id,
|
|
331
|
-
"content": f"PoCo is thinking with {provider_name}...",
|
|
332
|
-
}
|
|
333
|
-
],
|
|
407
|
+
"elements": elements,
|
|
334
408
|
},
|
|
335
409
|
}
|
|
410
|
+
|
|
411
|
+
def create_streaming_answer_card(self, provider_name: str) -> Tuple[str, str]:
|
|
412
|
+
"""Create a reusable streaming card entity for one answer."""
|
|
413
|
+
element_id = "answer_stream"
|
|
414
|
+
card = self.answer_card(
|
|
415
|
+
provider_name,
|
|
416
|
+
f"PoCo is thinking with {provider_name}...",
|
|
417
|
+
state="running",
|
|
418
|
+
stop_enabled=True,
|
|
419
|
+
streaming=True,
|
|
420
|
+
element_id=element_id,
|
|
421
|
+
)
|
|
336
422
|
return self.app._messenger.create_card_entity(card), element_id
|
|
337
423
|
|
|
338
424
|
def cleanup_session(self, session: TurnSession) -> None:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import fcntl
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import threading
|
|
@@ -13,111 +14,155 @@ LOG = logging.getLogger("poco.relay")
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class ThreadStore:
|
|
16
|
-
"""Persists worker-to-thread bindings."""
|
|
17
|
+
"""Persists worker-to-thread bindings with cross-process safety."""
|
|
17
18
|
|
|
18
19
|
def __init__(self, path: Path) -> None:
|
|
19
20
|
self._path = path
|
|
20
21
|
self._lock = threading.Lock()
|
|
21
22
|
self._data: Dict[str, str] = {}
|
|
22
|
-
self.
|
|
23
|
+
self._refresh()
|
|
23
24
|
|
|
24
|
-
def
|
|
25
|
+
def _load_disk(self) -> Dict[str, str]:
|
|
25
26
|
if not self._path.exists():
|
|
26
|
-
return
|
|
27
|
+
return {}
|
|
27
28
|
try:
|
|
28
29
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
29
30
|
if isinstance(raw, dict):
|
|
30
|
-
|
|
31
|
+
return {str(k): str(v) for k, v in raw.items()}
|
|
31
32
|
except Exception:
|
|
32
33
|
LOG.warning("Failed to load thread state from %s", self._path)
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
def _refresh(self) -> None:
|
|
37
|
+
self._data = self._load_disk()
|
|
33
38
|
|
|
34
39
|
def _save(self) -> None:
|
|
35
|
-
self._path.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with self._path.open("a+", encoding="utf-8") as handle:
|
|
42
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
43
|
+
disk_data = self._load_disk()
|
|
44
|
+
disk_data.update(self._data)
|
|
45
|
+
self._data = disk_data
|
|
46
|
+
handle.seek(0)
|
|
47
|
+
handle.truncate()
|
|
48
|
+
handle.write(json.dumps(self._data, ensure_ascii=False, indent=2))
|
|
49
|
+
handle.flush()
|
|
50
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
39
51
|
|
|
40
52
|
def get(self, chat_id: str) -> Optional[str]:
|
|
41
53
|
with self._lock:
|
|
54
|
+
self._refresh()
|
|
42
55
|
return self._data.get(chat_id)
|
|
43
56
|
|
|
44
57
|
def set(self, chat_id: str, thread_id: str) -> None:
|
|
45
58
|
with self._lock:
|
|
59
|
+
self._refresh()
|
|
46
60
|
self._data[chat_id] = thread_id
|
|
47
61
|
self._save()
|
|
48
62
|
|
|
49
63
|
def clear(self, chat_id: str) -> None:
|
|
50
64
|
with self._lock:
|
|
65
|
+
self._refresh()
|
|
51
66
|
if chat_id in self._data:
|
|
52
67
|
del self._data[chat_id]
|
|
53
68
|
self._save()
|
|
54
69
|
|
|
55
70
|
|
|
56
71
|
class WorkerStore:
|
|
57
|
-
"""Persists per-group worker configuration."""
|
|
72
|
+
"""Persists per-group worker configuration with cross-process safety."""
|
|
58
73
|
|
|
59
74
|
def __init__(self, path: Path) -> None:
|
|
60
75
|
self._path = path
|
|
61
76
|
self._lock = threading.Lock()
|
|
62
77
|
self._data: Dict[str, Dict[str, object]] = {}
|
|
63
|
-
self.
|
|
78
|
+
self._refresh()
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _normalize_payload(payload: object) -> Optional[Dict[str, object]]:
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
return None
|
|
84
|
+
alias = str(payload.get("alias", "")).strip()
|
|
85
|
+
provider = str(payload.get("provider", "")).strip().lower() or "codex"
|
|
86
|
+
backend = str(payload.get("backend", "")).strip().lower()
|
|
87
|
+
if not backend:
|
|
88
|
+
backend = "openai" if provider == "codex" else ""
|
|
89
|
+
mode = str(payload.get("mode", "auto")).strip() or "auto"
|
|
90
|
+
enabled = bool(payload.get("enabled", False))
|
|
91
|
+
cwd = str(payload.get("cwd", "")).strip()
|
|
92
|
+
model = str(payload.get("model", "")).strip()
|
|
93
|
+
return {
|
|
94
|
+
"alias": alias,
|
|
95
|
+
"provider": provider,
|
|
96
|
+
"backend": backend,
|
|
97
|
+
"mode": mode,
|
|
98
|
+
"model": model,
|
|
99
|
+
"enabled": enabled,
|
|
100
|
+
"cwd": cwd,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def _load_disk(self) -> Dict[str, Dict[str, object]]:
|
|
66
104
|
if not self._path.exists():
|
|
67
|
-
return
|
|
105
|
+
return {}
|
|
68
106
|
try:
|
|
69
107
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
70
108
|
if isinstance(raw, dict):
|
|
71
109
|
cleaned: Dict[str, Dict[str, object]] = {}
|
|
72
110
|
for worker_id, payload in raw.items():
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
backend = str(payload.get("backend", "")).strip().lower()
|
|
78
|
-
if not backend:
|
|
79
|
-
backend = "openai" if provider == "codex" else ""
|
|
80
|
-
mode = str(payload.get("mode", "auto")).strip() or "auto"
|
|
81
|
-
enabled = bool(payload.get("enabled", False))
|
|
82
|
-
cwd = str(payload.get("cwd", "")).strip()
|
|
83
|
-
model = str(payload.get("model", "")).strip()
|
|
84
|
-
cleaned[str(worker_id)] = {
|
|
85
|
-
"alias": alias,
|
|
86
|
-
"provider": provider,
|
|
87
|
-
"backend": backend,
|
|
88
|
-
"mode": mode,
|
|
89
|
-
"model": model,
|
|
90
|
-
"enabled": enabled,
|
|
91
|
-
"cwd": cwd,
|
|
92
|
-
}
|
|
93
|
-
self._data = cleaned
|
|
111
|
+
normalized = self._normalize_payload(payload)
|
|
112
|
+
if normalized is not None:
|
|
113
|
+
cleaned[str(worker_id)] = normalized
|
|
114
|
+
return cleaned
|
|
94
115
|
except Exception:
|
|
95
116
|
LOG.warning("Failed to load worker state from %s", self._path)
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
def _refresh(self) -> None:
|
|
120
|
+
self._data = self._load_disk()
|
|
96
121
|
|
|
97
122
|
def _save(self) -> None:
|
|
98
|
-
self._path.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
with self._path.open("a+", encoding="utf-8") as handle:
|
|
125
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
126
|
+
disk_data = self._load_disk()
|
|
127
|
+
disk_data.update(self._data)
|
|
128
|
+
self._data = disk_data
|
|
129
|
+
handle.seek(0)
|
|
130
|
+
handle.truncate()
|
|
131
|
+
handle.write(json.dumps(self._data, ensure_ascii=False, indent=2))
|
|
132
|
+
handle.flush()
|
|
133
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
134
|
+
|
|
135
|
+
def has_worker(self, worker_id: str) -> bool:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._refresh()
|
|
138
|
+
return worker_id in self._data
|
|
102
139
|
|
|
103
140
|
def alias_for(self, worker_id: str) -> str:
|
|
104
141
|
with self._lock:
|
|
105
|
-
|
|
142
|
+
self._refresh()
|
|
143
|
+
payload = self._data.get(worker_id) or {}
|
|
106
144
|
return str(payload.get("alias", "")).strip()
|
|
107
145
|
|
|
108
146
|
def provider_for(self, worker_id: str) -> str:
|
|
109
147
|
with self._lock:
|
|
110
|
-
|
|
148
|
+
self._refresh()
|
|
149
|
+
payload = self._data.get(worker_id) or {}
|
|
111
150
|
return str(payload.get("provider", "")).strip().lower()
|
|
112
151
|
|
|
113
152
|
def backend_for(self, worker_id: str) -> str:
|
|
114
153
|
with self._lock:
|
|
115
|
-
|
|
116
|
-
|
|
154
|
+
self._refresh()
|
|
155
|
+
payload = self._data.get(worker_id) or {}
|
|
156
|
+
provider = str(payload.get("provider", "")).strip().lower()
|
|
157
|
+
backend = str(payload.get("backend", "")).strip().lower()
|
|
158
|
+
if not backend and provider == "codex":
|
|
159
|
+
return "openai"
|
|
160
|
+
return backend
|
|
117
161
|
|
|
118
162
|
def set_provider(self, worker_id: str, provider: str) -> None:
|
|
119
163
|
provider_value = provider.strip().lower()
|
|
120
164
|
with self._lock:
|
|
165
|
+
self._refresh()
|
|
121
166
|
payload = self._ensure_record_locked(worker_id)
|
|
122
167
|
payload["provider"] = provider_value
|
|
123
168
|
payload["backend"] = ""
|
|
@@ -127,6 +172,7 @@ class WorkerStore:
|
|
|
127
172
|
def set_backend(self, worker_id: str, backend: str) -> None:
|
|
128
173
|
backend_value = backend.strip().lower()
|
|
129
174
|
with self._lock:
|
|
175
|
+
self._refresh()
|
|
130
176
|
payload = self._ensure_record_locked(worker_id)
|
|
131
177
|
payload["backend"] = backend_value
|
|
132
178
|
payload["model"] = ""
|
|
@@ -135,6 +181,7 @@ class WorkerStore:
|
|
|
135
181
|
def set_alias(self, worker_id: str, alias: str) -> None:
|
|
136
182
|
alias = alias.strip()
|
|
137
183
|
with self._lock:
|
|
184
|
+
self._refresh()
|
|
138
185
|
for existing_worker_id, payload in list(self._data.items()):
|
|
139
186
|
if existing_worker_id != worker_id and payload.get("alias", "") == alias:
|
|
140
187
|
raise ValueError(f"别名 {alias} 已被 worker {existing_worker_id} 使用。")
|
|
@@ -147,6 +194,7 @@ class WorkerStore:
|
|
|
147
194
|
if not alias_value:
|
|
148
195
|
return False
|
|
149
196
|
with self._lock:
|
|
197
|
+
self._refresh()
|
|
150
198
|
for worker_id, payload in self._data.items():
|
|
151
199
|
if except_worker_id and worker_id == except_worker_id:
|
|
152
200
|
continue
|
|
@@ -156,55 +204,65 @@ class WorkerStore:
|
|
|
156
204
|
|
|
157
205
|
def clear_alias(self, worker_id: str) -> None:
|
|
158
206
|
with self._lock:
|
|
207
|
+
self._refresh()
|
|
159
208
|
payload = self._ensure_record_locked(worker_id)
|
|
160
209
|
payload["alias"] = ""
|
|
161
210
|
self._save()
|
|
162
211
|
|
|
163
212
|
def mode_for(self, worker_id: str) -> str:
|
|
164
213
|
with self._lock:
|
|
165
|
-
|
|
214
|
+
self._refresh()
|
|
215
|
+
payload = self._data.get(worker_id) or {}
|
|
166
216
|
return str(payload.get("mode", "auto")).strip() or "auto"
|
|
167
217
|
|
|
168
218
|
def set_mode(self, worker_id: str, mode: str) -> None:
|
|
169
219
|
with self._lock:
|
|
220
|
+
self._refresh()
|
|
170
221
|
payload = self._ensure_record_locked(worker_id)
|
|
171
222
|
payload["mode"] = mode
|
|
172
223
|
self._save()
|
|
173
224
|
|
|
174
225
|
def enabled_for(self, worker_id: str) -> bool:
|
|
175
226
|
with self._lock:
|
|
176
|
-
|
|
227
|
+
self._refresh()
|
|
228
|
+
payload = self._data.get(worker_id) or {}
|
|
177
229
|
return bool(payload.get("enabled", False))
|
|
178
230
|
|
|
179
231
|
def set_enabled(self, worker_id: str, enabled: bool) -> None:
|
|
180
232
|
with self._lock:
|
|
233
|
+
self._refresh()
|
|
181
234
|
payload = self._ensure_record_locked(worker_id)
|
|
182
235
|
payload["enabled"] = enabled
|
|
183
236
|
self._save()
|
|
184
237
|
|
|
185
238
|
def ensure_worker(self, worker_id: str) -> None:
|
|
186
239
|
with self._lock:
|
|
240
|
+
self._refresh()
|
|
187
241
|
self._ensure_record_locked(worker_id)
|
|
188
242
|
self._save()
|
|
189
243
|
|
|
190
244
|
def cwd_for(self, worker_id: str) -> str:
|
|
191
245
|
with self._lock:
|
|
192
|
-
|
|
246
|
+
self._refresh()
|
|
247
|
+
payload = self._data.get(worker_id) or {}
|
|
193
248
|
return str(payload.get("cwd", "")).strip()
|
|
194
249
|
|
|
195
250
|
def set_cwd(self, worker_id: str, cwd: str) -> None:
|
|
196
251
|
with self._lock:
|
|
252
|
+
self._refresh()
|
|
197
253
|
payload = self._ensure_record_locked(worker_id)
|
|
198
254
|
payload["cwd"] = cwd.strip()
|
|
199
255
|
self._save()
|
|
200
256
|
|
|
201
257
|
def model_for(self, worker_id: str) -> str:
|
|
202
258
|
with self._lock:
|
|
203
|
-
|
|
259
|
+
self._refresh()
|
|
260
|
+
payload = self._data.get(worker_id) or {}
|
|
204
261
|
return str(payload.get("model", "")).strip()
|
|
205
262
|
|
|
206
263
|
def set_model(self, worker_id: str, model: str) -> None:
|
|
207
264
|
with self._lock:
|
|
265
|
+
self._refresh()
|
|
208
266
|
payload = self._ensure_record_locked(worker_id)
|
|
209
267
|
payload["model"] = model.strip()
|
|
210
268
|
self._save()
|
|
@@ -214,6 +272,7 @@ class WorkerStore:
|
|
|
214
272
|
if not key:
|
|
215
273
|
return None
|
|
216
274
|
with self._lock:
|
|
275
|
+
self._refresh()
|
|
217
276
|
if key in self._data:
|
|
218
277
|
return key
|
|
219
278
|
for worker_id, payload in self._data.items():
|
|
@@ -223,10 +282,12 @@ class WorkerStore:
|
|
|
223
282
|
|
|
224
283
|
def items(self) -> list[tuple[str, str]]:
|
|
225
284
|
with self._lock:
|
|
285
|
+
self._refresh()
|
|
226
286
|
return sorted((worker_id, payload.get("alias", "")) for worker_id, payload in self._data.items())
|
|
227
287
|
|
|
228
288
|
def remove(self, worker_id: str) -> None:
|
|
229
289
|
with self._lock:
|
|
290
|
+
self._refresh()
|
|
230
291
|
if worker_id in self._data:
|
|
231
292
|
del self._data[worker_id]
|
|
232
293
|
self._save()
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
import threading
|
|
3
4
|
import time
|
|
4
5
|
from collections import deque
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
from typing import Any, Dict, Optional
|
|
6
8
|
|
|
9
|
+
import fcntl
|
|
10
|
+
|
|
7
11
|
from .relay import AppConfig, ProviderConfig, RelayApp
|
|
8
12
|
from .config import (
|
|
9
13
|
INPUT_IDS,
|
|
14
|
+
STATE_DIR,
|
|
10
15
|
THREAD_STATE_PATH,
|
|
11
16
|
WORKER_STATE_PATH,
|
|
12
17
|
ConfigStore,
|
|
13
18
|
config_ready,
|
|
19
|
+
missing_required_config_paths,
|
|
14
20
|
normalize_config_key,
|
|
15
21
|
parse_config_value,
|
|
16
22
|
set_nested,
|
|
@@ -47,6 +53,7 @@ class RelayRunner:
|
|
|
47
53
|
self._thread: Optional[threading.Thread] = None
|
|
48
54
|
self._last_error: str = ""
|
|
49
55
|
self._started_at: Optional[float] = None
|
|
56
|
+
self._lock_file = None
|
|
50
57
|
self._lock = threading.Lock()
|
|
51
58
|
|
|
52
59
|
def status(self) -> Dict[str, Any]:
|
|
@@ -61,6 +68,11 @@ class RelayRunner:
|
|
|
61
68
|
with self._lock:
|
|
62
69
|
if self._thread is not None and self._thread.is_alive():
|
|
63
70
|
return False
|
|
71
|
+
lock_file = self._acquire_single_instance_lock()
|
|
72
|
+
if lock_file is None:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
"Another PoCo relay process is already running. Stop the other `poco` process first."
|
|
75
|
+
)
|
|
64
76
|
self._last_error = ""
|
|
65
77
|
app_config = build_app_config(config)
|
|
66
78
|
thread = threading.Thread(
|
|
@@ -71,6 +83,7 @@ class RelayRunner:
|
|
|
71
83
|
)
|
|
72
84
|
self._thread = thread
|
|
73
85
|
self._started_at = time.time()
|
|
86
|
+
self._lock_file = lock_file
|
|
74
87
|
thread.start()
|
|
75
88
|
return True
|
|
76
89
|
|
|
@@ -82,6 +95,36 @@ class RelayRunner:
|
|
|
82
95
|
with self._lock:
|
|
83
96
|
self._last_error = str(exc)
|
|
84
97
|
LOG.exception("Relay runtime crashed")
|
|
98
|
+
finally:
|
|
99
|
+
self._release_single_instance_lock()
|
|
100
|
+
|
|
101
|
+
def _acquire_single_instance_lock(self):
|
|
102
|
+
lock_path = Path(STATE_DIR) / "relay.lock"
|
|
103
|
+
handle = lock_path.open("w", encoding="utf-8")
|
|
104
|
+
try:
|
|
105
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
106
|
+
except BlockingIOError:
|
|
107
|
+
handle.close()
|
|
108
|
+
return None
|
|
109
|
+
handle.seek(0)
|
|
110
|
+
handle.truncate()
|
|
111
|
+
handle.write(str(os.getpid()))
|
|
112
|
+
handle.flush()
|
|
113
|
+
return handle
|
|
114
|
+
|
|
115
|
+
def _release_single_instance_lock(self) -> None:
|
|
116
|
+
with self._lock:
|
|
117
|
+
handle = self._lock_file
|
|
118
|
+
self._lock_file = None
|
|
119
|
+
self._thread = None
|
|
120
|
+
self._started_at = None
|
|
121
|
+
if handle is None:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
handle.close()
|
|
85
128
|
|
|
86
129
|
|
|
87
130
|
def build_app_config(config: Dict[str, Any]) -> AppConfig:
|
|
@@ -162,5 +205,6 @@ class PoCoService:
|
|
|
162
205
|
def start_relay(self) -> bool:
|
|
163
206
|
config = self.load_config()
|
|
164
207
|
if not config_ready(config):
|
|
165
|
-
|
|
208
|
+
missing = ", ".join(missing_required_config_paths(config))
|
|
209
|
+
raise ValueError(f"配置还不完整,缺少:{missing}")
|
|
166
210
|
return self.relay.start(config)
|
|
@@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal
|
|
|
8
8
|
from textual.widgets import Input, Static
|
|
9
9
|
|
|
10
10
|
from .. import __version__
|
|
11
|
-
from ..config import config_ready
|
|
11
|
+
from ..config import config_ready, missing_required_config_paths
|
|
12
12
|
from ..providers import model_choices
|
|
13
13
|
from .resources import POCO_ICON, STRINGS, TUI_CSS
|
|
14
14
|
from .menus.config import (
|
|
@@ -291,6 +291,22 @@ class PoCoTui(App[None]):
|
|
|
291
291
|
value = value.get(part, "")
|
|
292
292
|
return str(value or "")
|
|
293
293
|
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _missing_config_field_name(path: str) -> str:
|
|
296
|
+
"""Returns a short human-readable field name for one required path."""
|
|
297
|
+
mapping = {
|
|
298
|
+
"feishu.app_id": "app_id",
|
|
299
|
+
"feishu.app_secret": "app_secret",
|
|
300
|
+
}
|
|
301
|
+
return mapping.get(path, path)
|
|
302
|
+
|
|
303
|
+
def _missing_config_fields_text(self, config: dict) -> str:
|
|
304
|
+
"""Returns a compact comma-separated summary of missing required fields."""
|
|
305
|
+
return ", ".join(
|
|
306
|
+
self._missing_config_field_name(path)
|
|
307
|
+
for path in missing_required_config_paths(config)
|
|
308
|
+
)
|
|
309
|
+
|
|
294
310
|
def compose(self) -> ComposeResult:
|
|
295
311
|
"""Builds the Textual widget tree."""
|
|
296
312
|
with Horizontal(id="main_panel"):
|
|
@@ -315,7 +331,11 @@ class PoCoTui(App[None]):
|
|
|
315
331
|
config = self._service.load_config()
|
|
316
332
|
if self._focus_config or not config_ready(config):
|
|
317
333
|
self._enter_config_mode()
|
|
318
|
-
self.
|
|
334
|
+
missing = self._missing_config_fields_text(config)
|
|
335
|
+
if missing:
|
|
336
|
+
self._set_message(self._t("config_missing", fields=missing))
|
|
337
|
+
else:
|
|
338
|
+
self._set_message(self._t("config_required"))
|
|
319
339
|
return
|
|
320
340
|
self._service.start_relay()
|
|
321
341
|
self._set_message(self._t("relay_started"), transient=True)
|
|
@@ -337,13 +357,19 @@ class PoCoTui(App[None]):
|
|
|
337
357
|
relay_style = "#3fb950" if relay["running"] else "#f85149"
|
|
338
358
|
config_status = self._t("ready") if config_ready(config) else self._t("needs_config")
|
|
339
359
|
config_style = "#3fb950" if config_ready(config) else "#f2cc60"
|
|
340
|
-
|
|
360
|
+
lines = [
|
|
341
361
|
f"[bold #fb923c]{POCO_ICON}[/]\n"
|
|
342
362
|
f"[bold #fb923c]PoCo v{__version__}[/]\n"
|
|
343
363
|
"\n"
|
|
344
364
|
f"{self._t('relay')}: [bold {relay_style}]{relay_status}[/]\n"
|
|
345
365
|
f"{self._t('settings')}: [bold {config_style}]{config_status}[/]"
|
|
346
|
-
|
|
366
|
+
]
|
|
367
|
+
if not config_ready(config):
|
|
368
|
+
missing = self._missing_config_fields_text(config)
|
|
369
|
+
if missing:
|
|
370
|
+
lines.append("")
|
|
371
|
+
lines.append(f"[bold #f2cc60]{self._t('missing_label')}[/]: [#f2cc60]{missing}[/]")
|
|
372
|
+
return "\n".join(lines)
|
|
347
373
|
|
|
348
374
|
def _right_panel_text(self, config: dict, relay: dict) -> str:
|
|
349
375
|
"""Builds the right panel text for the current view."""
|
|
@@ -103,9 +103,14 @@ STRINGS = {
|
|
|
103
103
|
"current_secret": {"en": "current secret", "zh": "当前密钥"},
|
|
104
104
|
"empty": {"en": "empty", "zh": "空"},
|
|
105
105
|
"config_required": {
|
|
106
|
-
"en": "Config is incomplete. Open
|
|
107
|
-
"zh": "
|
|
106
|
+
"en": "Config is incomplete. Open Bot (feishu) and finish the required fields.",
|
|
107
|
+
"zh": "配置还不完整。请打开 Bot (feishu) 并完成必填项。",
|
|
108
108
|
},
|
|
109
|
+
"config_missing": {
|
|
110
|
+
"en": "Missing in Bot (feishu): {fields}",
|
|
111
|
+
"zh": "Bot (feishu) 缺少:{fields}",
|
|
112
|
+
},
|
|
113
|
+
"missing_label": {"en": "Missing", "zh": "缺少"},
|
|
109
114
|
"relay_started": {"en": "PoCo relay is running.", "zh": "PoCo relay 已启动。"},
|
|
110
115
|
"relay_already_running": {"en": "PoCo relay is already running.", "zh": "PoCo relay 已经在运行。"},
|
|
111
116
|
"left_config": {"en": "Left config mode.", "zh": "已退出配置模式。"},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|