nonebot-plugin-codex 0.1.2__tar.gz → 0.1.4__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.
Files changed (24) hide show
  1. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/PKG-INFO +34 -10
  2. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/README.md +33 -9
  3. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/pyproject.toml +1 -1
  4. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/__init__.py +74 -4
  5. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/service.py +362 -0
  6. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/telegram.py +276 -12
  7. nonebot_plugin_codex-0.1.4/src/nonebot_plugin_codex/telegram_commands.py +112 -0
  8. nonebot_plugin_codex-0.1.4/tests/test_plugin_entry.py +70 -0
  9. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_release_notes.py +2 -1
  10. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_service.py +50 -0
  11. nonebot_plugin_codex-0.1.4/tests/test_telegram_commands.py +57 -0
  12. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_telegram_handlers.py +367 -4
  13. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/LICENSE +0 -0
  14. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/config.py +0 -0
  15. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/native_client.py +0 -0
  16. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/runtime.py +0 -0
  17. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
  18. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/__init__.py +0 -0
  19. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/conftest.py +0 -0
  20. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_config.py +0 -0
  21. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_native_client.py +0 -0
  22. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_plugin_meta.py +0 -0
  23. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_runtime.py +0 -0
  24. {nonebot_plugin_codex-0.1.2 → nonebot_plugin_codex-0.1.4}/tests/test_telegram_rendering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-codex
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Telegram bridge plugin for driving Codex from NoneBot
5
5
  Author-Email: ttiee <469784630@qq.com>
6
6
  License: GPL-3.0-or-later
@@ -121,19 +121,38 @@ codex_workdir = "/home/yourname"
121
121
  一个典型工作流通常是这样的:
122
122
 
123
123
  ```text
124
- /codex 帮我检查当前仓库为什么测试失败
124
+ /codex
125
+ /panel
125
126
  /cd /home/yourname/projects/demo
126
- /permission danger
127
127
  /mode resume
128
128
  然后继续直接发送普通文本消息续聊
129
129
  ```
130
130
 
131
+ `/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
132
+
133
+ `/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
134
+
135
+ 你也可以直接把首条任务跟在 `/codex` 后面:
136
+
137
+ ```text
138
+ /codex 帮我检查当前仓库为什么测试失败
139
+ /permission danger
140
+ ```
141
+
131
142
  你也可以把一次性任务交给 `exec` 模式:
132
143
 
133
144
  ```text
134
145
  /exec 用三点总结这个仓库 README 还缺什么
135
146
  ```
136
147
 
148
+ 如果你希望显式打开引导入口,也可以使用:
149
+
150
+ ```text
151
+ /help
152
+ /start
153
+ /panel
154
+ ```
155
+
137
156
  ## 配置说明
138
157
 
139
158
  完整配置如下,配置名与当前实现保持一致:
@@ -178,17 +197,21 @@ codex_stream_read_limit = 1048576
178
197
 
179
198
  | 命令 | 说明 |
180
199
  | --- | --- |
181
- | `/codex [prompt]` | 连接 Codex,会附带发送首条 prompt |
200
+ | `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
201
+ | `/help` | 打开使用引导面板 |
202
+ | `/start` | 打开使用引导面板 |
203
+ | `/panel` | 打开统一工作台面板 |
204
+ | `/status` | 打开统一工作台面板 |
182
205
  | `/mode [resume\|exec]` | 查看或切换默认模式 |
183
206
  | `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
184
- | `/new` | 清空当前聊天绑定的会话 |
185
- | `/stop` | 断开当前聊天的 Codex 会话 |
186
- | `/models` | 查看可用模型 |
187
- | `/model [slug]` | 查看或切换模型 |
207
+ | `/new` | 新建当前聊天会话 |
208
+ | `/stop` | 停止当前聊天中的 Codex |
209
+ | `/models` | 查看可用模型列表 |
210
+ | `/model [slug]` | 查看或切换当前模型 |
188
211
  | `/effort [high\|xhigh]` | 查看或切换推理强度 |
189
212
  | `/permission [safe\|danger]` | 查看或切换权限模式 |
190
- | `/pwd` | 查看当前工作目录与当前设置 |
191
- | `/cd [path]` | 直接切换目录;不带参数时打开目录浏览器 |
213
+ | `/pwd` | 查看当前工作目录和设置 |
214
+ | `/cd [path]` | 切换目录或打开目录浏览器 |
192
215
  | `/home` | 将工作目录重置到 Home |
193
216
  | `/sessions` | 打开历史会话浏览器 |
194
217
 
@@ -212,6 +235,7 @@ codex_stream_read_limit = 1048576
212
235
 
213
236
  ## 目录与历史会话
214
237
 
238
+ - `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
215
239
  - `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
216
240
  - `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
217
241
  - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
@@ -108,19 +108,38 @@ codex_workdir = "/home/yourname"
108
108
  一个典型工作流通常是这样的:
109
109
 
110
110
  ```text
111
- /codex 帮我检查当前仓库为什么测试失败
111
+ /codex
112
+ /panel
112
113
  /cd /home/yourname/projects/demo
113
- /permission danger
114
114
  /mode resume
115
115
  然后继续直接发送普通文本消息续聊
116
116
  ```
117
117
 
118
+ `/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
119
+
120
+ `/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
121
+
122
+ 你也可以直接把首条任务跟在 `/codex` 后面:
123
+
124
+ ```text
125
+ /codex 帮我检查当前仓库为什么测试失败
126
+ /permission danger
127
+ ```
128
+
118
129
  你也可以把一次性任务交给 `exec` 模式:
119
130
 
120
131
  ```text
121
132
  /exec 用三点总结这个仓库 README 还缺什么
122
133
  ```
123
134
 
135
+ 如果你希望显式打开引导入口,也可以使用:
136
+
137
+ ```text
138
+ /help
139
+ /start
140
+ /panel
141
+ ```
142
+
124
143
  ## 配置说明
125
144
 
126
145
  完整配置如下,配置名与当前实现保持一致:
@@ -165,17 +184,21 @@ codex_stream_read_limit = 1048576
165
184
 
166
185
  | 命令 | 说明 |
167
186
  | --- | --- |
168
- | `/codex [prompt]` | 连接 Codex,会附带发送首条 prompt |
187
+ | `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
188
+ | `/help` | 打开使用引导面板 |
189
+ | `/start` | 打开使用引导面板 |
190
+ | `/panel` | 打开统一工作台面板 |
191
+ | `/status` | 打开统一工作台面板 |
169
192
  | `/mode [resume\|exec]` | 查看或切换默认模式 |
170
193
  | `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
171
- | `/new` | 清空当前聊天绑定的会话 |
172
- | `/stop` | 断开当前聊天的 Codex 会话 |
173
- | `/models` | 查看可用模型 |
174
- | `/model [slug]` | 查看或切换模型 |
194
+ | `/new` | 新建当前聊天会话 |
195
+ | `/stop` | 停止当前聊天中的 Codex |
196
+ | `/models` | 查看可用模型列表 |
197
+ | `/model [slug]` | 查看或切换当前模型 |
175
198
  | `/effort [high\|xhigh]` | 查看或切换推理强度 |
176
199
  | `/permission [safe\|danger]` | 查看或切换权限模式 |
177
- | `/pwd` | 查看当前工作目录与当前设置 |
178
- | `/cd [path]` | 直接切换目录;不带参数时打开目录浏览器 |
200
+ | `/pwd` | 查看当前工作目录和设置 |
201
+ | `/cd [path]` | 切换目录或打开目录浏览器 |
179
202
  | `/home` | 将工作目录重置到 Home |
180
203
  | `/sessions` | 打开历史会话浏览器 |
181
204
 
@@ -199,6 +222,7 @@ codex_stream_read_limit = 1048576
199
222
 
200
223
  ## 目录与历史会话
201
224
 
225
+ - `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
202
226
  - `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
203
227
  - `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
204
228
  - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-codex"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Telegram bridge plugin for driving Codex from NoneBot"
5
5
  authors = [
6
6
  { name = "ttiee", email = "469784630@qq.com" },
@@ -3,14 +3,21 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from nonebot import get_plugin_config, on_command, on_message, on_type, require
6
+ from nonebot.drivers import Driver
7
+ from nonebot.log import logger
6
8
  from nonebot.plugin import PluginMetadata
7
9
  from nonebot.params import CommandArg
8
10
  from nonebot.adapters.telegram import Bot
9
11
  from nonebot.adapters.telegram.message import Message
10
12
  from nonebot.adapters.telegram.event import MessageEvent, CallbackQueryEvent
13
+ from nonebot.adapters.telegram.model import (
14
+ BotCommandScopeAllGroupChats,
15
+ BotCommandScopeAllPrivateChats,
16
+ )
11
17
 
12
18
  from .config import Config
13
19
  from .telegram import TelegramHandlers
20
+ from .telegram_commands import build_plugin_usage, build_telegram_commands
14
21
  from .native_client import NativeCodexClient
15
22
  from .runtime import build_service_settings
16
23
  from .service import CodexBridgeService
@@ -18,10 +25,7 @@ from .service import CodexBridgeService
18
25
  __plugin_meta__ = PluginMetadata(
19
26
  name="Codex",
20
27
  description="Telegram bridge plugin for driving Codex from NoneBot",
21
- usage=(
22
- "/codex [prompt], /mode, /exec, /new, /stop, /models, /model, /effort, "
23
- "/permission, /pwd, /cd, /home, /sessions"
24
- ),
28
+ usage=build_plugin_usage(),
25
29
  homepage="https://github.com/ttiee/nonebot-plugin-codex",
26
30
  type="application",
27
31
  config=Config,
@@ -58,8 +62,34 @@ service = CodexBridgeService(
58
62
  )
59
63
  handlers = TelegramHandlers(service)
60
64
 
65
+
66
+ async def sync_telegram_commands(bot: Bot) -> bool:
67
+ synced = True
68
+ scopes = (
69
+ BotCommandScopeAllPrivateChats(),
70
+ BotCommandScopeAllGroupChats(),
71
+ )
72
+ commands = build_telegram_commands()
73
+ for scope in scopes:
74
+ try:
75
+ await bot.set_my_commands(commands, scope=scope)
76
+ except Exception as exc:
77
+ logger.warning(f"Telegram 命令菜单同步失败({scope.type}):{exc}")
78
+ synced = False
79
+ return synced
80
+
61
81
  if _runtime_ready:
82
+ @Driver.on_bot_connect
83
+ async def _sync_telegram_commands(bot: Bot) -> None:
84
+ if not isinstance(bot, Bot):
85
+ return
86
+ await sync_telegram_commands(bot)
87
+
62
88
  codex_cmd = on_command("codex", priority=10, block=True)
89
+ help_cmd = on_command("help", priority=10, block=True)
90
+ start_cmd = on_command("start", priority=10, block=True)
91
+ panel_cmd = on_command("panel", priority=10, block=True)
92
+ status_cmd = on_command("status", priority=10, block=True)
63
93
  mode_cmd = on_command("mode", priority=10, block=True)
64
94
  exec_cmd = on_command("exec", priority=10, block=True)
65
95
  new_cmd = on_command("new", priority=10, block=True)
@@ -91,6 +121,18 @@ if _runtime_ready:
91
121
  block=True,
92
122
  rule=handlers.is_setting_callback,
93
123
  )
124
+ onboarding_callback = on_type(
125
+ CallbackQueryEvent,
126
+ priority=10,
127
+ block=True,
128
+ rule=handlers.is_onboarding_callback,
129
+ )
130
+ workspace_callback = on_type(
131
+ CallbackQueryEvent,
132
+ priority=10,
133
+ block=True,
134
+ rule=handlers.is_workspace_callback,
135
+ )
94
136
 
95
137
  @codex_cmd.handle()
96
138
  async def _handle_codex(
@@ -98,6 +140,22 @@ if _runtime_ready:
98
140
  ) -> None:
99
141
  await handlers.handle_codex(bot, event, args)
100
142
 
143
+ @help_cmd.handle()
144
+ async def _handle_help(bot: Bot, event: MessageEvent) -> None:
145
+ await handlers.handle_help(bot, event)
146
+
147
+ @start_cmd.handle()
148
+ async def _handle_start(bot: Bot, event: MessageEvent) -> None:
149
+ await handlers.handle_start(bot, event)
150
+
151
+ @panel_cmd.handle()
152
+ async def _handle_panel(bot: Bot, event: MessageEvent) -> None:
153
+ await handlers.handle_panel(bot, event)
154
+
155
+ @status_cmd.handle()
156
+ async def _handle_status(bot: Bot, event: MessageEvent) -> None:
157
+ await handlers.handle_status(bot, event)
158
+
101
159
  @mode_cmd.handle()
102
160
  async def _handle_mode(
103
161
  bot: Bot, event: MessageEvent, args: Message = CommandArg()
@@ -172,6 +230,18 @@ if _runtime_ready:
172
230
  async def _handle_setting_callback(bot: Bot, event: CallbackQueryEvent) -> None:
173
231
  await handlers.handle_setting_callback(bot, event)
174
232
 
233
+ @onboarding_callback.handle()
234
+ async def _handle_onboarding_callback(
235
+ bot: Bot, event: CallbackQueryEvent
236
+ ) -> None:
237
+ await handlers.handle_onboarding_callback(bot, event)
238
+
239
+ @workspace_callback.handle()
240
+ async def _handle_workspace_callback(
241
+ bot: Bot, event: CallbackQueryEvent
242
+ ) -> None:
243
+ await handlers.handle_workspace_callback(bot, event)
244
+
175
245
  @follow_up.handle()
176
246
  async def _handle_follow_up(bot: Bot, event: MessageEvent) -> None:
177
247
  await handlers.handle_follow_up(bot, event)
@@ -38,6 +38,10 @@ HISTORY_STALE_MESSAGE = "历史会话面板已失效,请重新执行 /sessions
38
38
  SETTING_CALLBACK_PREFIX = "csp"
39
39
  SETTING_STALE_MESSAGE = "设置面板已失效,请重新执行对应命令"
40
40
  SUPPORTED_SETTING_PANELS = {"mode", "model", "effort", "permission"}
41
+ ONBOARDING_CALLBACK_PREFIX = "cop"
42
+ ONBOARDING_STALE_MESSAGE = "引导面板已失效,请重新执行 /codex"
43
+ WORKSPACE_CALLBACK_PREFIX = "cwp"
44
+ WORKSPACE_STALE_MESSAGE = "工作台面板已失效,请重新执行 /panel"
41
45
 
42
46
 
43
47
  @dataclass(slots=True)
@@ -195,6 +199,22 @@ class SettingPanelState:
195
199
  message_id: int | None = None
196
200
 
197
201
 
202
+ @dataclass(slots=True)
203
+ class OnboardingPanelState:
204
+ chat_key: str
205
+ token: str
206
+ version: int
207
+ message_id: int | None = None
208
+
209
+
210
+ @dataclass(slots=True)
211
+ class WorkspacePanelState:
212
+ chat_key: str
213
+ token: str
214
+ version: int
215
+ message_id: int | None = None
216
+
217
+
198
218
  def build_chat_key(chat_type: str, chat_id: int) -> str:
199
219
  if chat_type == "private":
200
220
  return f"private_{chat_id}"
@@ -330,6 +350,38 @@ def decode_setting_callback(payload: str) -> tuple[str, int, str, str | None]:
330
350
  return token, version, action, value
331
351
 
332
352
 
353
+ def encode_onboarding_callback(token: str, version: int, action: str) -> str:
354
+ return f"{ONBOARDING_CALLBACK_PREFIX}:{token}:{version}:{action}"
355
+
356
+
357
+ def decode_onboarding_callback(payload: str) -> tuple[str, int, str]:
358
+ parts = payload.split(":")
359
+ if len(parts) != 4 or parts[0] != ONBOARDING_CALLBACK_PREFIX:
360
+ raise ValueError("无效的引导回调。")
361
+ token = parts[1]
362
+ try:
363
+ version = int(parts[2])
364
+ except ValueError as exc:
365
+ raise ValueError("无效的引导回调。") from exc
366
+ return token, version, parts[3]
367
+
368
+
369
+ def encode_workspace_callback(token: str, version: int, action: str) -> str:
370
+ return f"{WORKSPACE_CALLBACK_PREFIX}:{token}:{version}:{action}"
371
+
372
+
373
+ def decode_workspace_callback(payload: str) -> tuple[str, int, str]:
374
+ parts = payload.split(":")
375
+ if len(parts) != 4 or parts[0] != WORKSPACE_CALLBACK_PREFIX:
376
+ raise ValueError("无效的工作台回调。")
377
+ token = parts[1]
378
+ try:
379
+ version = int(parts[2])
380
+ except ValueError as exc:
381
+ raise ValueError("无效的工作台回调。") from exc
382
+ return token, version, parts[3]
383
+
384
+
333
385
  def parse_event_line(line: str) -> dict[str, Any] | None:
334
386
  try:
335
387
  payload = json.loads(line)
@@ -501,6 +553,8 @@ class CodexBridgeService:
501
553
  self.directory_browsers: dict[str, DirectoryBrowserState] = {}
502
554
  self.history_browsers: dict[str, HistoryBrowserState] = {}
503
555
  self.setting_panels: dict[str, SettingPanelState] = {}
556
+ self.onboarding_panels: dict[str, OnboardingPanelState] = {}
557
+ self.workspace_panels: dict[str, WorkspacePanelState] = {}
504
558
  self._native_history_entries: list[HistoricalSessionSummary] = []
505
559
  self._native_history_loaded = False
506
560
  self._history_log_cache: dict[str, HistoryLogCacheEntry] = {}
@@ -1639,6 +1693,314 @@ class CodexBridgeService:
1639
1693
  self.get_setting_panel(chat_key, token=token, version=version)
1640
1694
  self.setting_panels.pop(chat_key, None)
1641
1695
 
1696
+ def _replace_onboarding_panel_state(
1697
+ self,
1698
+ chat_key: str,
1699
+ *,
1700
+ previous: OnboardingPanelState | None = None,
1701
+ ) -> OnboardingPanelState:
1702
+ state = OnboardingPanelState(
1703
+ chat_key=chat_key,
1704
+ token=previous.token if previous else self._make_browser_token(),
1705
+ version=(previous.version + 1) if previous else 1,
1706
+ message_id=previous.message_id if previous else None,
1707
+ )
1708
+ self.onboarding_panels[chat_key] = state
1709
+ return state
1710
+
1711
+ def open_onboarding_panel(self, chat_key: str) -> OnboardingPanelState:
1712
+ self.get_preferences(chat_key)
1713
+ return self._replace_onboarding_panel_state(chat_key)
1714
+
1715
+ def get_onboarding_panel(
1716
+ self,
1717
+ chat_key: str,
1718
+ token: str | None = None,
1719
+ version: int | None = None,
1720
+ ) -> OnboardingPanelState:
1721
+ state = self.onboarding_panels.get(chat_key)
1722
+ if state is None:
1723
+ raise ValueError(ONBOARDING_STALE_MESSAGE)
1724
+ if token is not None and state.token != token:
1725
+ raise ValueError(ONBOARDING_STALE_MESSAGE)
1726
+ if version is not None and state.version != version:
1727
+ raise ValueError(ONBOARDING_STALE_MESSAGE)
1728
+ return state
1729
+
1730
+ def remember_onboarding_panel_message(
1731
+ self,
1732
+ chat_key: str,
1733
+ token: str,
1734
+ message_id: int | None,
1735
+ ) -> None:
1736
+ if message_id is None:
1737
+ return
1738
+ panel = self.get_onboarding_panel(chat_key, token=token)
1739
+ panel.message_id = message_id
1740
+
1741
+ def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> None:
1742
+ self.get_onboarding_panel(chat_key, token=token, version=version)
1743
+ self.onboarding_panels.pop(chat_key, None)
1744
+
1745
+ def _replace_workspace_panel_state(
1746
+ self,
1747
+ chat_key: str,
1748
+ *,
1749
+ previous: WorkspacePanelState | None = None,
1750
+ ) -> WorkspacePanelState:
1751
+ state = WorkspacePanelState(
1752
+ chat_key=chat_key,
1753
+ token=previous.token if previous else self._make_browser_token(),
1754
+ version=(previous.version + 1) if previous else 1,
1755
+ message_id=previous.message_id if previous else None,
1756
+ )
1757
+ self.workspace_panels[chat_key] = state
1758
+ return state
1759
+
1760
+ def open_workspace_panel(self, chat_key: str) -> WorkspacePanelState:
1761
+ self.get_preferences(chat_key)
1762
+ return self._replace_workspace_panel_state(chat_key)
1763
+
1764
+ def get_workspace_panel(
1765
+ self,
1766
+ chat_key: str,
1767
+ token: str | None = None,
1768
+ version: int | None = None,
1769
+ ) -> WorkspacePanelState:
1770
+ state = self.workspace_panels.get(chat_key)
1771
+ if state is None:
1772
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1773
+ if token is not None and state.token != token:
1774
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1775
+ if version is not None and state.version != version:
1776
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1777
+ return state
1778
+
1779
+ def remember_workspace_panel_message(
1780
+ self,
1781
+ chat_key: str,
1782
+ token: str,
1783
+ message_id: int | None,
1784
+ ) -> None:
1785
+ if message_id is None:
1786
+ return
1787
+ panel = self.get_workspace_panel(chat_key, token=token)
1788
+ panel.message_id = message_id
1789
+
1790
+ def close_workspace_panel(self, chat_key: str, token: str, version: int) -> None:
1791
+ self.get_workspace_panel(chat_key, token=token, version=version)
1792
+ self.workspace_panels.pop(chat_key, None)
1793
+
1794
+ def _workspace_active_mode(self, chat_key: str, preferences: ChatPreferences) -> str:
1795
+ session = self.sessions.get(chat_key)
1796
+ if session is not None and session.active_mode in {"resume", "exec"}:
1797
+ return session.active_mode
1798
+ return preferences.default_mode
1799
+
1800
+ def _workspace_session_summary(self, chat_key: str) -> str:
1801
+ session = self.sessions.get(chat_key)
1802
+ if session is None:
1803
+ return "未开始"
1804
+ active_mode = (
1805
+ session.active_mode
1806
+ if session.active_mode in {"resume", "exec"}
1807
+ else "resume"
1808
+ )
1809
+ thread_id = (
1810
+ self._current_exec_thread_id(session)
1811
+ if active_mode == "exec"
1812
+ else session.native_thread_id or session.thread_id
1813
+ )
1814
+ if not thread_id and not session.active:
1815
+ return "未开始"
1816
+ if not thread_id:
1817
+ return f"{active_mode} | 未绑定"
1818
+ return f"{active_mode} | {thread_id}"
1819
+
1820
+ def _workspace_recent_history_lines(self) -> list[str]:
1821
+ try:
1822
+ entries = self.list_history_sessions()[:2]
1823
+ except ValueError:
1824
+ return ["最近历史:不可用"]
1825
+ if not entries:
1826
+ return ["最近历史:无"]
1827
+ lines = ["最近历史:"]
1828
+ for entry in entries:
1829
+ lines.append(
1830
+ "- "
1831
+ f"{entry.thread_name} | "
1832
+ f"{self._format_history_relative_time(entry.updated_at)}"
1833
+ )
1834
+ return lines
1835
+
1836
+ def navigate_workspace_panel(
1837
+ self,
1838
+ chat_key: str,
1839
+ token: str,
1840
+ version: int,
1841
+ action: str,
1842
+ ) -> WorkspacePanelState:
1843
+ panel = self.get_workspace_panel(chat_key, token=token, version=version)
1844
+ if action != "refresh":
1845
+ raise ValueError("未知工作台操作。")
1846
+ return self._replace_workspace_panel_state(chat_key, previous=panel)
1847
+
1848
+ def render_workspace_panel(
1849
+ self, chat_key: str
1850
+ ) -> tuple[str, InlineKeyboardMarkup]:
1851
+ panel = self.get_workspace_panel(chat_key)
1852
+ preferences = self.get_preferences(chat_key)
1853
+ lines = [
1854
+ "当前工作台",
1855
+ f"当前模式:{self._workspace_active_mode(chat_key, preferences)}",
1856
+ f"当前设置:{format_preferences_summary(preferences)}",
1857
+ f"当前工作目录:{preferences.workdir}",
1858
+ f"当前会话:{self._workspace_session_summary(chat_key)}",
1859
+ *self._workspace_recent_history_lines(),
1860
+ ]
1861
+ keyboard = [
1862
+ [
1863
+ InlineKeyboardButton(
1864
+ text="模式",
1865
+ callback_data=encode_workspace_callback(
1866
+ panel.token, panel.version, "mode"
1867
+ ),
1868
+ ),
1869
+ InlineKeyboardButton(
1870
+ text="模型",
1871
+ callback_data=encode_workspace_callback(
1872
+ panel.token, panel.version, "model"
1873
+ ),
1874
+ ),
1875
+ InlineKeyboardButton(
1876
+ text="强度",
1877
+ callback_data=encode_workspace_callback(
1878
+ panel.token, panel.version, "effort"
1879
+ ),
1880
+ ),
1881
+ InlineKeyboardButton(
1882
+ text="权限",
1883
+ callback_data=encode_workspace_callback(
1884
+ panel.token, panel.version, "permission"
1885
+ ),
1886
+ ),
1887
+ ],
1888
+ [
1889
+ InlineKeyboardButton(
1890
+ text="目录",
1891
+ callback_data=encode_workspace_callback(
1892
+ panel.token, panel.version, "browse"
1893
+ ),
1894
+ ),
1895
+ InlineKeyboardButton(
1896
+ text="历史",
1897
+ callback_data=encode_workspace_callback(
1898
+ panel.token, panel.version, "history"
1899
+ ),
1900
+ ),
1901
+ ],
1902
+ [
1903
+ InlineKeyboardButton(
1904
+ text="新会话",
1905
+ callback_data=encode_workspace_callback(
1906
+ panel.token, panel.version, "new"
1907
+ ),
1908
+ ),
1909
+ InlineKeyboardButton(
1910
+ text="停止",
1911
+ callback_data=encode_workspace_callback(
1912
+ panel.token, panel.version, "stop"
1913
+ ),
1914
+ ),
1915
+ ],
1916
+ [
1917
+ InlineKeyboardButton(
1918
+ text="刷新",
1919
+ callback_data=encode_workspace_callback(
1920
+ panel.token, panel.version, "refresh"
1921
+ ),
1922
+ ),
1923
+ InlineKeyboardButton(
1924
+ text="关闭",
1925
+ callback_data=encode_workspace_callback(
1926
+ panel.token, panel.version, "close"
1927
+ ),
1928
+ ),
1929
+ ],
1930
+ ]
1931
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1932
+
1933
+ def render_onboarding_panel(
1934
+ self, chat_key: str
1935
+ ) -> tuple[str, InlineKeyboardMarkup]:
1936
+ panel = self.get_onboarding_panel(chat_key)
1937
+ preferences = self.get_preferences(chat_key)
1938
+ session = self.sessions.get(chat_key)
1939
+ active_mode = (
1940
+ session.active_mode
1941
+ if session is not None and session.active_mode in {"resume", "exec"}
1942
+ else preferences.default_mode
1943
+ )
1944
+ has_bound_session = bool(
1945
+ session
1946
+ and (
1947
+ session.active
1948
+ or session.thread_id
1949
+ or session.native_thread_id
1950
+ or session.exec_thread_id
1951
+ )
1952
+ )
1953
+ lines = [
1954
+ "开始使用 Codex",
1955
+ f"当前模式:{active_mode}",
1956
+ f"当前工作目录:{preferences.workdir}",
1957
+ f"当前设置:{format_preferences_summary(preferences)}",
1958
+ f"当前会话:{'可继续' if has_bound_session else '未开始'}",
1959
+ (
1960
+ "推荐:直接发送任务,或先切换目录;"
1961
+ "一次性任务用 /exec;恢复上下文看历史会话。"
1962
+ ),
1963
+ ]
1964
+ keyboard = [
1965
+ [
1966
+ InlineKeyboardButton(
1967
+ text="切换目录",
1968
+ callback_data=encode_onboarding_callback(
1969
+ panel.token, panel.version, "browse"
1970
+ ),
1971
+ ),
1972
+ InlineKeyboardButton(
1973
+ text="当前设置",
1974
+ callback_data=encode_onboarding_callback(
1975
+ panel.token, panel.version, "settings"
1976
+ ),
1977
+ ),
1978
+ ],
1979
+ [
1980
+ InlineKeyboardButton(
1981
+ text="历史会话",
1982
+ callback_data=encode_onboarding_callback(
1983
+ panel.token, panel.version, "history"
1984
+ ),
1985
+ ),
1986
+ InlineKeyboardButton(
1987
+ text="新开会话",
1988
+ callback_data=encode_onboarding_callback(
1989
+ panel.token, panel.version, "new"
1990
+ ),
1991
+ ),
1992
+ ],
1993
+ [
1994
+ InlineKeyboardButton(
1995
+ text="关闭",
1996
+ callback_data=encode_onboarding_callback(
1997
+ panel.token, panel.version, "close"
1998
+ ),
1999
+ )
2000
+ ],
2001
+ ]
2002
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
2003
+
1642
2004
  def navigate_setting_panel(
1643
2005
  self,
1644
2006
  chat_key: str,