nonebot-plugin-codex 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.
- nonebot_plugin_codex/__init__.py +165 -0
- nonebot_plugin_codex/config.py +84 -0
- nonebot_plugin_codex/native_client.py +419 -0
- nonebot_plugin_codex/service.py +2338 -0
- nonebot_plugin_codex/telegram.py +650 -0
- nonebot_plugin_codex-0.1.0.dist-info/METADATA +167 -0
- nonebot_plugin_codex-0.1.0.dist-info/RECORD +10 -0
- nonebot_plugin_codex-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_codex-0.1.0.dist-info/entry_points.txt +4 -0
- nonebot_plugin_codex-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,2338 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import secrets
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
from collections.abc import Callable, Awaitable
|
|
16
|
+
from dataclasses import field, asdict, dataclass
|
|
17
|
+
|
|
18
|
+
from nonebot.adapters.telegram.model import InlineKeyboardButton, InlineKeyboardMarkup
|
|
19
|
+
|
|
20
|
+
from .native_client import NativeCodexClient
|
|
21
|
+
|
|
22
|
+
ProgressCallback = Callable[[str], Awaitable[None]]
|
|
23
|
+
StreamTextCallback = Callable[[str], Awaitable[None]]
|
|
24
|
+
ProcessLauncher = Callable[..., Awaitable[Any]]
|
|
25
|
+
WhichResolver = Callable[[str], str | None]
|
|
26
|
+
|
|
27
|
+
VISIBLE_MODEL = "list"
|
|
28
|
+
SUPPORTED_EFFORT_COMMANDS = {"high", "xhigh"}
|
|
29
|
+
SUPPORTED_PERMISSION_MODES = {"safe", "danger"}
|
|
30
|
+
BROWSER_CALLBACK_PREFIX = "cdb"
|
|
31
|
+
BROWSER_PAGE_SIZE = 8
|
|
32
|
+
BROWSER_FILE_SUMMARY_LIMIT = 10
|
|
33
|
+
BROWSER_STALE_MESSAGE = "目录面板已失效,请重新执行 /cd"
|
|
34
|
+
HISTORY_CALLBACK_PREFIX = "chs"
|
|
35
|
+
HISTORY_PAGE_SIZE = 6
|
|
36
|
+
HISTORY_STALE_MESSAGE = "历史会话面板已失效,请重新执行 /sessions"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class CodexBridgeSettings:
|
|
41
|
+
binary: str = "codex"
|
|
42
|
+
workdir: str = field(default_factory=lambda: str(Path.home()))
|
|
43
|
+
kill_timeout: float = 5.0
|
|
44
|
+
progress_history: int = 6
|
|
45
|
+
diagnostic_history: int = 20
|
|
46
|
+
chunk_size: int = 3500
|
|
47
|
+
stream_read_limit: int = 1024 * 1024
|
|
48
|
+
models_cache_path: Path = field(
|
|
49
|
+
default_factory=lambda: Path.home() / ".codex" / "models_cache.json"
|
|
50
|
+
)
|
|
51
|
+
codex_config_path: Path = field(
|
|
52
|
+
default_factory=lambda: Path.home() / ".codex" / "config.toml"
|
|
53
|
+
)
|
|
54
|
+
preferences_path: Path = field(
|
|
55
|
+
default_factory=lambda: Path("data") / "codex_bridge" / "preferences.json"
|
|
56
|
+
)
|
|
57
|
+
session_index_path: Path = field(
|
|
58
|
+
default_factory=lambda: Path.home() / ".codex" / "session_index.jsonl"
|
|
59
|
+
)
|
|
60
|
+
sessions_dir: Path = field(
|
|
61
|
+
default_factory=lambda: Path.home() / ".codex" / "sessions"
|
|
62
|
+
)
|
|
63
|
+
archived_sessions_dir: Path = field(
|
|
64
|
+
default_factory=lambda: Path.home() / ".codex" / "archived_sessions"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class ModelInfo:
|
|
70
|
+
slug: str
|
|
71
|
+
display_name: str
|
|
72
|
+
visibility: str
|
|
73
|
+
priority: int
|
|
74
|
+
default_reasoning_level: str
|
|
75
|
+
supported_reasoning_levels: list[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class ChatPreferences:
|
|
80
|
+
model: str
|
|
81
|
+
reasoning_effort: str
|
|
82
|
+
permission_mode: str = "safe"
|
|
83
|
+
workdir: str = field(default_factory=lambda: str(Path.home()))
|
|
84
|
+
default_mode: str = "resume"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class ChatSession:
|
|
89
|
+
active: bool = False
|
|
90
|
+
active_mode: str = "resume"
|
|
91
|
+
native_thread_id: str | None = None
|
|
92
|
+
exec_thread_id: str | None = None
|
|
93
|
+
thread_id: str | None = None
|
|
94
|
+
strict_resume: bool = False
|
|
95
|
+
running: bool = False
|
|
96
|
+
process: Any = None
|
|
97
|
+
native_runner: Any = None
|
|
98
|
+
runner_task: asyncio.Task[Any] | None = None
|
|
99
|
+
progress_message_id: int | None = None
|
|
100
|
+
stream_message_id: int | None = None
|
|
101
|
+
last_agent_message: str = ""
|
|
102
|
+
last_stream_text: str = ""
|
|
103
|
+
last_stream_rendered_text: str = ""
|
|
104
|
+
stream_message_truncated: bool = False
|
|
105
|
+
progress_lines: list[str] = field(default_factory=list)
|
|
106
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
107
|
+
cancel_requested: bool = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(slots=True)
|
|
111
|
+
class RunResult:
|
|
112
|
+
exit_code: int
|
|
113
|
+
final_text: str = ""
|
|
114
|
+
thread_id: str | None = None
|
|
115
|
+
notice: str = ""
|
|
116
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
117
|
+
cancelled: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True)
|
|
121
|
+
class DirectoryEntry:
|
|
122
|
+
name: str
|
|
123
|
+
path: str
|
|
124
|
+
is_dir: bool = True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(slots=True)
|
|
128
|
+
class HistoricalSessionSummary:
|
|
129
|
+
session_id: str
|
|
130
|
+
thread_name: str
|
|
131
|
+
updated_at: str
|
|
132
|
+
kind: str = "exec"
|
|
133
|
+
cwd: str | None = None
|
|
134
|
+
source_kind: str | None = None
|
|
135
|
+
source_path: str | None = None
|
|
136
|
+
archived: bool = False
|
|
137
|
+
missing: bool = False
|
|
138
|
+
preview: str | None = None
|
|
139
|
+
last_user_text: str | None = None
|
|
140
|
+
last_assistant_text: str | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class HistoryBrowserState:
|
|
145
|
+
chat_key: str
|
|
146
|
+
page: int
|
|
147
|
+
token: str
|
|
148
|
+
version: int
|
|
149
|
+
entries: list[HistoricalSessionSummary]
|
|
150
|
+
scope: str = "menu"
|
|
151
|
+
selected_session_id: str | None = None
|
|
152
|
+
message_id: int | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True)
|
|
156
|
+
class DirectoryBrowserState:
|
|
157
|
+
chat_key: str
|
|
158
|
+
current_path: str
|
|
159
|
+
page: int
|
|
160
|
+
token: str
|
|
161
|
+
version: int
|
|
162
|
+
entries: list[DirectoryEntry]
|
|
163
|
+
show_hidden: bool = False
|
|
164
|
+
files: list[str] = field(default_factory=list)
|
|
165
|
+
message_id: int | None = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_chat_key(chat_type: str, chat_id: int) -> str:
|
|
169
|
+
if chat_type == "private":
|
|
170
|
+
return f"private_{chat_id}"
|
|
171
|
+
return f"group_{chat_id}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build_exec_argv(
|
|
175
|
+
binary: str,
|
|
176
|
+
workdir: str,
|
|
177
|
+
prompt: str,
|
|
178
|
+
*,
|
|
179
|
+
model: str,
|
|
180
|
+
reasoning_effort: str,
|
|
181
|
+
permission_mode: str,
|
|
182
|
+
thread_id: str | None = None,
|
|
183
|
+
) -> list[str]:
|
|
184
|
+
base_args = [
|
|
185
|
+
binary,
|
|
186
|
+
"exec",
|
|
187
|
+
]
|
|
188
|
+
if thread_id:
|
|
189
|
+
base_args.extend(["resume"])
|
|
190
|
+
base_args.extend(
|
|
191
|
+
[
|
|
192
|
+
"--json",
|
|
193
|
+
"--skip-git-repo-check",
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
if not thread_id:
|
|
197
|
+
base_args.extend(["-C", workdir])
|
|
198
|
+
base_args.extend(
|
|
199
|
+
[
|
|
200
|
+
"-m",
|
|
201
|
+
model,
|
|
202
|
+
"-c",
|
|
203
|
+
f'model_reasoning_effort="{reasoning_effort}"',
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
if permission_mode == "safe":
|
|
207
|
+
if thread_id:
|
|
208
|
+
base_args.append("--full-auto")
|
|
209
|
+
else:
|
|
210
|
+
base_args.extend(["--sandbox", "workspace-write"])
|
|
211
|
+
elif permission_mode == "danger":
|
|
212
|
+
base_args.append("--dangerously-bypass-approvals-and-sandbox")
|
|
213
|
+
else:
|
|
214
|
+
raise ValueError(f"Unsupported permission mode: {permission_mode}")
|
|
215
|
+
if thread_id:
|
|
216
|
+
base_args.append(thread_id)
|
|
217
|
+
base_args.append(prompt)
|
|
218
|
+
return base_args
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def encode_browser_callback(
|
|
222
|
+
token: str,
|
|
223
|
+
version: int,
|
|
224
|
+
action: str,
|
|
225
|
+
index: int | None = None,
|
|
226
|
+
) -> str:
|
|
227
|
+
suffix = "" if index is None else f":{index}"
|
|
228
|
+
return f"{BROWSER_CALLBACK_PREFIX}:{token}:{version}:{action}{suffix}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def decode_browser_callback(payload: str) -> tuple[str, int, str, int | None]:
|
|
232
|
+
parts = payload.split(":")
|
|
233
|
+
if len(parts) not in {4, 5} or parts[0] != BROWSER_CALLBACK_PREFIX:
|
|
234
|
+
raise ValueError("无效的目录回调。")
|
|
235
|
+
token = parts[1]
|
|
236
|
+
try:
|
|
237
|
+
version = int(parts[2])
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
raise ValueError("无效的目录回调。") from exc
|
|
240
|
+
action = parts[3]
|
|
241
|
+
index: int | None = None
|
|
242
|
+
if len(parts) == 5:
|
|
243
|
+
try:
|
|
244
|
+
index = int(parts[4])
|
|
245
|
+
except ValueError as exc:
|
|
246
|
+
raise ValueError("无效的目录回调。") from exc
|
|
247
|
+
return token, version, action, index
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def encode_history_callback(
|
|
251
|
+
token: str,
|
|
252
|
+
version: int,
|
|
253
|
+
action: str,
|
|
254
|
+
index: int | None = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
suffix = "" if index is None else f":{index}"
|
|
257
|
+
return f"{HISTORY_CALLBACK_PREFIX}:{token}:{version}:{action}{suffix}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def decode_history_callback(payload: str) -> tuple[str, int, str, int | None]:
|
|
261
|
+
parts = payload.split(":")
|
|
262
|
+
if len(parts) not in {4, 5} or parts[0] != HISTORY_CALLBACK_PREFIX:
|
|
263
|
+
raise ValueError("无效的历史会话回调。")
|
|
264
|
+
token = parts[1]
|
|
265
|
+
try:
|
|
266
|
+
version = int(parts[2])
|
|
267
|
+
except ValueError as exc:
|
|
268
|
+
raise ValueError("无效的历史会话回调。") from exc
|
|
269
|
+
action = parts[3]
|
|
270
|
+
index: int | None = None
|
|
271
|
+
if len(parts) == 5:
|
|
272
|
+
try:
|
|
273
|
+
index = int(parts[4])
|
|
274
|
+
except ValueError as exc:
|
|
275
|
+
raise ValueError("无效的历史会话回调。") from exc
|
|
276
|
+
return token, version, action, index
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_event_line(line: str) -> dict[str, Any] | None:
|
|
280
|
+
try:
|
|
281
|
+
payload = json.loads(line)
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
return None
|
|
284
|
+
if isinstance(payload, dict) and isinstance(payload.get("type"), str):
|
|
285
|
+
return payload
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def should_forward_follow_up(session: ChatSession | None, text: str) -> bool:
|
|
290
|
+
if session is None or not session.active or session.running:
|
|
291
|
+
return False
|
|
292
|
+
plain = text.strip()
|
|
293
|
+
return bool(plain and not plain.startswith("/"))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def chunk_text(text: str, limit: int) -> list[str]:
|
|
297
|
+
if not text:
|
|
298
|
+
return []
|
|
299
|
+
chunks: list[str] = []
|
|
300
|
+
remaining = text
|
|
301
|
+
while remaining:
|
|
302
|
+
if len(remaining) <= limit:
|
|
303
|
+
chunks.append(remaining)
|
|
304
|
+
break
|
|
305
|
+
split_at = remaining.rfind("\n", 0, limit)
|
|
306
|
+
if split_at <= 0:
|
|
307
|
+
split_at = limit
|
|
308
|
+
chunks.append(remaining[:split_at].rstrip())
|
|
309
|
+
remaining = remaining[split_at:].lstrip()
|
|
310
|
+
return [chunk for chunk in chunks if chunk]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def format_result_text(result: RunResult) -> str:
|
|
314
|
+
parts: list[str] = []
|
|
315
|
+
if result.notice:
|
|
316
|
+
parts.append(result.notice)
|
|
317
|
+
if result.cancelled:
|
|
318
|
+
parts.append("Codex 已中断。")
|
|
319
|
+
elif result.final_text:
|
|
320
|
+
parts.append(result.final_text)
|
|
321
|
+
elif result.exit_code == 0:
|
|
322
|
+
parts.append("Codex 已完成,但没有返回可展示的最终文本。")
|
|
323
|
+
else:
|
|
324
|
+
parts.append("Codex 执行失败。")
|
|
325
|
+
if result.diagnostics:
|
|
326
|
+
parts.append("\n".join(result.diagnostics[-5:]))
|
|
327
|
+
return "\n\n".join(parts)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def format_preferences_summary(preferences: ChatPreferences) -> str:
|
|
331
|
+
return (
|
|
332
|
+
f"模型: {preferences.model} | 推理: {preferences.reasoning_effort} | "
|
|
333
|
+
f"权限: {preferences.permission_mode}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def format_file_summary(files: list[str]) -> str:
|
|
338
|
+
if not files:
|
|
339
|
+
return "文件:无"
|
|
340
|
+
preview = ",".join(files[:BROWSER_FILE_SUMMARY_LIMIT])
|
|
341
|
+
remaining = len(files) - BROWSER_FILE_SUMMARY_LIMIT
|
|
342
|
+
suffix = f" 等 {len(files)} 个" if remaining > 0 else ""
|
|
343
|
+
return f"文件:{preview}{suffix}"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _trim_command(command: str, limit: int = 120) -> str:
|
|
347
|
+
compact = " ".join(command.split())
|
|
348
|
+
if len(compact) <= limit:
|
|
349
|
+
return compact
|
|
350
|
+
return f"{compact[: limit - 3]}..."
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _append_progress_line(session: ChatSession, line: str, limit: int) -> None:
|
|
354
|
+
session.progress_lines.append(line)
|
|
355
|
+
if len(session.progress_lines) > limit:
|
|
356
|
+
del session.progress_lines[:-limit]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _append_diagnostic(session: ChatSession, line: str, limit: int) -> None:
|
|
360
|
+
session.diagnostics.append(line)
|
|
361
|
+
if len(session.diagnostics) > limit:
|
|
362
|
+
del session.diagnostics[:-limit]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _apply_event(
|
|
366
|
+
session: ChatSession,
|
|
367
|
+
event: dict[str, Any],
|
|
368
|
+
*,
|
|
369
|
+
progress_history: int,
|
|
370
|
+
) -> tuple[bool, str | None]:
|
|
371
|
+
event_type = event["type"]
|
|
372
|
+
if event_type == "thread.started":
|
|
373
|
+
thread_id = event.get("thread_id")
|
|
374
|
+
if isinstance(thread_id, str) and thread_id:
|
|
375
|
+
session.thread_id = thread_id
|
|
376
|
+
return True, None
|
|
377
|
+
if event_type == "turn.started":
|
|
378
|
+
_append_progress_line(session, "开始处理请求", progress_history)
|
|
379
|
+
return True, None
|
|
380
|
+
if event_type not in {"item.started", "item.completed"}:
|
|
381
|
+
return False, None
|
|
382
|
+
|
|
383
|
+
item = event.get("item")
|
|
384
|
+
if not isinstance(item, dict):
|
|
385
|
+
return False, None
|
|
386
|
+
|
|
387
|
+
item_type = item.get("type")
|
|
388
|
+
if item_type == "command_execution":
|
|
389
|
+
command = _trim_command(str(item.get("command", "")))
|
|
390
|
+
prefix = "执行" if event_type == "item.started" else "完成"
|
|
391
|
+
_append_progress_line(session, f"{prefix}: {command}", progress_history)
|
|
392
|
+
return True, None
|
|
393
|
+
|
|
394
|
+
if item_type == "agent_message":
|
|
395
|
+
text = item.get("text")
|
|
396
|
+
if isinstance(text, str) and text.strip():
|
|
397
|
+
stripped = text.strip()
|
|
398
|
+
session.last_agent_message = stripped
|
|
399
|
+
if stripped != session.last_stream_text:
|
|
400
|
+
session.last_stream_text = stripped
|
|
401
|
+
return False, stripped
|
|
402
|
+
return False, None
|
|
403
|
+
|
|
404
|
+
return False, None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def render_progress_text(session: ChatSession, *, header: str | None = None) -> str:
|
|
408
|
+
parts: list[str] = []
|
|
409
|
+
if header:
|
|
410
|
+
parts.append(header)
|
|
411
|
+
if not session.progress_lines:
|
|
412
|
+
parts.append("Codex 运行中…")
|
|
413
|
+
else:
|
|
414
|
+
body = "\n".join(f"- {line}" for line in session.progress_lines)
|
|
415
|
+
parts.append(f"Codex 运行中…\n{body}")
|
|
416
|
+
return "\n".join(parts)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def terminate_process(process: Any, timeout: float) -> None:
|
|
420
|
+
if process is None:
|
|
421
|
+
return
|
|
422
|
+
if getattr(process, "returncode", None) is not None:
|
|
423
|
+
return
|
|
424
|
+
process.terminate()
|
|
425
|
+
try:
|
|
426
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
427
|
+
except asyncio.TimeoutError:
|
|
428
|
+
process.kill()
|
|
429
|
+
await process.wait()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class CodexBridgeService:
|
|
433
|
+
def __init__(
|
|
434
|
+
self,
|
|
435
|
+
settings: CodexBridgeSettings,
|
|
436
|
+
*,
|
|
437
|
+
launcher: ProcessLauncher | None = None,
|
|
438
|
+
native_client: NativeCodexClient | None = None,
|
|
439
|
+
which_resolver: WhichResolver = shutil.which,
|
|
440
|
+
) -> None:
|
|
441
|
+
self.settings = settings
|
|
442
|
+
self.launcher = launcher or asyncio.create_subprocess_exec
|
|
443
|
+
self.native_client = native_client
|
|
444
|
+
self.which_resolver = which_resolver
|
|
445
|
+
self.sessions: dict[str, ChatSession] = {}
|
|
446
|
+
self.preference_overrides = self._load_preferences()
|
|
447
|
+
self.directory_browsers: dict[str, DirectoryBrowserState] = {}
|
|
448
|
+
self.history_browsers: dict[str, HistoryBrowserState] = {}
|
|
449
|
+
self._native_history_entries: list[HistoricalSessionSummary] = []
|
|
450
|
+
self._native_history_loaded = False
|
|
451
|
+
|
|
452
|
+
def _spawn_native_client(self) -> Any:
|
|
453
|
+
if self.native_client is None:
|
|
454
|
+
return None
|
|
455
|
+
if isinstance(self.native_client, NativeCodexClient):
|
|
456
|
+
return self.native_client.clone()
|
|
457
|
+
return self.native_client
|
|
458
|
+
|
|
459
|
+
async def _close_native_runner(self, runner: Any) -> None:
|
|
460
|
+
if runner is None:
|
|
461
|
+
return
|
|
462
|
+
close = getattr(runner, "close", None)
|
|
463
|
+
if close is None:
|
|
464
|
+
return
|
|
465
|
+
result = close()
|
|
466
|
+
if inspect.isawaitable(result):
|
|
467
|
+
await result
|
|
468
|
+
|
|
469
|
+
def _load_history_index(self) -> tuple[dict[str, tuple[str, str]], bool]:
|
|
470
|
+
path = self.settings.session_index_path
|
|
471
|
+
try:
|
|
472
|
+
raw_lines = path.read_text(encoding="utf-8").splitlines()
|
|
473
|
+
except FileNotFoundError:
|
|
474
|
+
return {}, False
|
|
475
|
+
except OSError as exc:
|
|
476
|
+
raise ValueError("无法读取 Codex 历史会话索引。") from exc
|
|
477
|
+
|
|
478
|
+
indexed: dict[str, tuple[str, str]] = {}
|
|
479
|
+
for line in raw_lines:
|
|
480
|
+
try:
|
|
481
|
+
payload = json.loads(line)
|
|
482
|
+
except json.JSONDecodeError:
|
|
483
|
+
continue
|
|
484
|
+
if not isinstance(payload, dict):
|
|
485
|
+
continue
|
|
486
|
+
session_id = payload.get("id")
|
|
487
|
+
thread_name = payload.get("thread_name")
|
|
488
|
+
updated_at = payload.get("updated_at")
|
|
489
|
+
if not all(
|
|
490
|
+
isinstance(value, str) and value
|
|
491
|
+
for value in (session_id, thread_name, updated_at)
|
|
492
|
+
):
|
|
493
|
+
continue
|
|
494
|
+
indexed[session_id] = (thread_name, updated_at)
|
|
495
|
+
return indexed, True
|
|
496
|
+
|
|
497
|
+
def _normalize_history_title(self, text: str) -> str | None:
|
|
498
|
+
plain = " ".join(text.split())
|
|
499
|
+
if not plain:
|
|
500
|
+
return None
|
|
501
|
+
if plain.startswith("# AGENTS.md instructions"):
|
|
502
|
+
return None
|
|
503
|
+
if plain.startswith("<environment_context>"):
|
|
504
|
+
return None
|
|
505
|
+
if self._is_noise_history_text(plain):
|
|
506
|
+
return None
|
|
507
|
+
if len(plain) <= 120:
|
|
508
|
+
return plain
|
|
509
|
+
return f"{plain[:117]}..."
|
|
510
|
+
|
|
511
|
+
def _normalize_history_preview(self, text: str) -> str | None:
|
|
512
|
+
plain = " ".join(text.split())
|
|
513
|
+
if not plain or self._is_noise_history_text(plain):
|
|
514
|
+
return None
|
|
515
|
+
if len(plain) <= 240:
|
|
516
|
+
return plain
|
|
517
|
+
return f"{plain[:237]}..."
|
|
518
|
+
|
|
519
|
+
def _parse_history_time(self, value: str) -> datetime | None:
|
|
520
|
+
plain = value.strip()
|
|
521
|
+
if not plain:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
timestamp = float(plain)
|
|
526
|
+
except ValueError:
|
|
527
|
+
timestamp = None
|
|
528
|
+
if timestamp is not None:
|
|
529
|
+
if abs(timestamp) >= 1_000_000_000_000:
|
|
530
|
+
timestamp /= 1000
|
|
531
|
+
try:
|
|
532
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
533
|
+
except (OverflowError, OSError, ValueError):
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
normalized = plain
|
|
537
|
+
if normalized.endswith("Z"):
|
|
538
|
+
normalized = f"{normalized[:-1]}+00:00"
|
|
539
|
+
try:
|
|
540
|
+
parsed = datetime.fromisoformat(normalized)
|
|
541
|
+
except ValueError:
|
|
542
|
+
return None
|
|
543
|
+
if parsed.tzinfo is None:
|
|
544
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
545
|
+
return parsed.astimezone(timezone.utc)
|
|
546
|
+
|
|
547
|
+
def _format_history_relative_time(self, value: str) -> str:
|
|
548
|
+
parsed = self._parse_history_time(value)
|
|
549
|
+
if parsed is None:
|
|
550
|
+
return value
|
|
551
|
+
|
|
552
|
+
elapsed_seconds = max(
|
|
553
|
+
0,
|
|
554
|
+
int((datetime.now(timezone.utc) - parsed).total_seconds()),
|
|
555
|
+
)
|
|
556
|
+
if elapsed_seconds < 60:
|
|
557
|
+
return "刚刚"
|
|
558
|
+
|
|
559
|
+
minutes = elapsed_seconds // 60
|
|
560
|
+
if minutes < 60:
|
|
561
|
+
return f"{minutes} 分钟前"
|
|
562
|
+
|
|
563
|
+
hours = elapsed_seconds // 3600
|
|
564
|
+
if hours < 24:
|
|
565
|
+
return f"{hours} 小时前"
|
|
566
|
+
|
|
567
|
+
days = elapsed_seconds // 86400
|
|
568
|
+
if days < 7:
|
|
569
|
+
return f"{days} 天前"
|
|
570
|
+
|
|
571
|
+
weeks = days // 7
|
|
572
|
+
if days < 30:
|
|
573
|
+
return f"{weeks} 周前"
|
|
574
|
+
|
|
575
|
+
months = days // 30
|
|
576
|
+
if days < 365:
|
|
577
|
+
return f"{months} 个月前"
|
|
578
|
+
|
|
579
|
+
years = days // 365
|
|
580
|
+
return f"{years} 年前"
|
|
581
|
+
|
|
582
|
+
def _format_history_local_time(self, value: str) -> str:
|
|
583
|
+
parsed = self._parse_history_time(value)
|
|
584
|
+
if parsed is None:
|
|
585
|
+
return value
|
|
586
|
+
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
587
|
+
|
|
588
|
+
def _is_noise_history_text(self, text: str) -> bool:
|
|
589
|
+
lowered = text.strip().lower()
|
|
590
|
+
if not lowered:
|
|
591
|
+
return True
|
|
592
|
+
if lowered.startswith("# agents.md instructions"):
|
|
593
|
+
return True
|
|
594
|
+
if lowered.startswith("<environment_context>"):
|
|
595
|
+
return True
|
|
596
|
+
if "you are a helpful assistant" in lowered and (
|
|
597
|
+
"generate a concise ui title" in lowered
|
|
598
|
+
or "you will be presented with a user prompt" in lowered
|
|
599
|
+
or "generate a clear, informative task title" in lowered
|
|
600
|
+
):
|
|
601
|
+
return True
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
def _extract_history_title(self, payload: dict[str, Any]) -> str | None:
|
|
605
|
+
payload_type = payload.get("type")
|
|
606
|
+
if payload_type == "event_msg":
|
|
607
|
+
event = payload.get("payload")
|
|
608
|
+
if not isinstance(event, dict) or event.get("type") != "user_message":
|
|
609
|
+
return None
|
|
610
|
+
message = event.get("message")
|
|
611
|
+
if isinstance(message, str):
|
|
612
|
+
return self._normalize_history_title(message)
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
if payload_type != "response_item":
|
|
616
|
+
return None
|
|
617
|
+
item = payload.get("payload")
|
|
618
|
+
if not isinstance(item, dict):
|
|
619
|
+
return None
|
|
620
|
+
if item.get("type") != "message" or item.get("role") != "user":
|
|
621
|
+
return None
|
|
622
|
+
content = item.get("content")
|
|
623
|
+
if not isinstance(content, list):
|
|
624
|
+
return None
|
|
625
|
+
for part in content:
|
|
626
|
+
if not isinstance(part, dict) or part.get("type") != "input_text":
|
|
627
|
+
continue
|
|
628
|
+
text = part.get("text")
|
|
629
|
+
if isinstance(text, str):
|
|
630
|
+
title = self._normalize_history_title(text)
|
|
631
|
+
if title:
|
|
632
|
+
return title
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
def _extract_history_message(
|
|
636
|
+
self,
|
|
637
|
+
payload: dict[str, Any],
|
|
638
|
+
) -> tuple[str, str] | None:
|
|
639
|
+
payload_type = payload.get("type")
|
|
640
|
+
if payload_type == "event_msg":
|
|
641
|
+
event = payload.get("payload")
|
|
642
|
+
if not isinstance(event, dict) or event.get("type") != "user_message":
|
|
643
|
+
return None
|
|
644
|
+
message = event.get("message")
|
|
645
|
+
if not isinstance(message, str):
|
|
646
|
+
return None
|
|
647
|
+
normalized = self._normalize_history_preview(message)
|
|
648
|
+
if normalized is None:
|
|
649
|
+
return None
|
|
650
|
+
return "user", normalized
|
|
651
|
+
|
|
652
|
+
if payload_type != "response_item":
|
|
653
|
+
return None
|
|
654
|
+
item = payload.get("payload")
|
|
655
|
+
if not isinstance(item, dict):
|
|
656
|
+
return None
|
|
657
|
+
if item.get("type") != "message":
|
|
658
|
+
return None
|
|
659
|
+
role = item.get("role")
|
|
660
|
+
if role not in {"user", "assistant"}:
|
|
661
|
+
return None
|
|
662
|
+
content = item.get("content")
|
|
663
|
+
if not isinstance(content, list):
|
|
664
|
+
return None
|
|
665
|
+
supported_types = {"input_text"} if role == "user" else {"output_text"}
|
|
666
|
+
texts: list[str] = []
|
|
667
|
+
for part in content:
|
|
668
|
+
if not isinstance(part, dict) or part.get("type") not in supported_types:
|
|
669
|
+
continue
|
|
670
|
+
text = part.get("text")
|
|
671
|
+
if isinstance(text, str):
|
|
672
|
+
normalized = self._normalize_history_preview(text)
|
|
673
|
+
if normalized:
|
|
674
|
+
texts.append(normalized)
|
|
675
|
+
if not texts:
|
|
676
|
+
return None
|
|
677
|
+
return role, " ".join(texts)
|
|
678
|
+
|
|
679
|
+
def _parse_history_session_file(
|
|
680
|
+
self,
|
|
681
|
+
path: Path,
|
|
682
|
+
*,
|
|
683
|
+
archived: bool,
|
|
684
|
+
indexed: tuple[str, str] | None,
|
|
685
|
+
) -> HistoricalSessionSummary | None:
|
|
686
|
+
session_id: str | None = None
|
|
687
|
+
cwd: str | None = None
|
|
688
|
+
source_kind: str | None = None
|
|
689
|
+
discovered_title: str | None = None
|
|
690
|
+
discovered_updated_at: str | None = None
|
|
691
|
+
last_user_text: str | None = None
|
|
692
|
+
last_assistant_text: str | None = None
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
696
|
+
for line in handle:
|
|
697
|
+
try:
|
|
698
|
+
payload = json.loads(line)
|
|
699
|
+
except json.JSONDecodeError:
|
|
700
|
+
continue
|
|
701
|
+
if not isinstance(payload, dict):
|
|
702
|
+
continue
|
|
703
|
+
timestamp = payload.get("timestamp")
|
|
704
|
+
if isinstance(timestamp, str) and timestamp:
|
|
705
|
+
discovered_updated_at = timestamp
|
|
706
|
+
if discovered_title is None:
|
|
707
|
+
discovered_title = self._extract_history_title(payload)
|
|
708
|
+
extracted_message = self._extract_history_message(payload)
|
|
709
|
+
if extracted_message is not None:
|
|
710
|
+
role, text = extracted_message
|
|
711
|
+
if role == "user":
|
|
712
|
+
last_user_text = text
|
|
713
|
+
elif role == "assistant":
|
|
714
|
+
last_assistant_text = text
|
|
715
|
+
if payload.get("type") != "session_meta":
|
|
716
|
+
continue
|
|
717
|
+
meta = payload.get("payload")
|
|
718
|
+
if not isinstance(meta, dict):
|
|
719
|
+
continue
|
|
720
|
+
meta_session_id = meta.get("id")
|
|
721
|
+
if isinstance(meta_session_id, str) and meta_session_id:
|
|
722
|
+
session_id = meta_session_id
|
|
723
|
+
meta_cwd = meta.get("cwd")
|
|
724
|
+
if isinstance(meta_cwd, str) and meta_cwd:
|
|
725
|
+
cwd = meta_cwd
|
|
726
|
+
meta_source = meta.get("source")
|
|
727
|
+
if isinstance(meta_source, str) and meta_source:
|
|
728
|
+
source_kind = meta_source
|
|
729
|
+
meta_updated_at = meta.get("timestamp")
|
|
730
|
+
if isinstance(meta_updated_at, str) and meta_updated_at:
|
|
731
|
+
discovered_updated_at = meta_updated_at
|
|
732
|
+
except OSError:
|
|
733
|
+
if indexed is None:
|
|
734
|
+
return None
|
|
735
|
+
return HistoricalSessionSummary(
|
|
736
|
+
session_id=session_id or path.stem,
|
|
737
|
+
thread_name=indexed[0],
|
|
738
|
+
updated_at=indexed[1],
|
|
739
|
+
kind="exec",
|
|
740
|
+
source_path=str(path),
|
|
741
|
+
archived=archived,
|
|
742
|
+
missing=True,
|
|
743
|
+
preview=indexed[0],
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
if not session_id:
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
if indexed is not None:
|
|
750
|
+
thread_name, updated_at = indexed
|
|
751
|
+
else:
|
|
752
|
+
thread_name = discovered_title or session_id
|
|
753
|
+
updated_at = discovered_updated_at or ""
|
|
754
|
+
|
|
755
|
+
preview = last_assistant_text or last_user_text or thread_name
|
|
756
|
+
return HistoricalSessionSummary(
|
|
757
|
+
session_id=session_id,
|
|
758
|
+
thread_name=thread_name,
|
|
759
|
+
updated_at=updated_at,
|
|
760
|
+
kind="exec",
|
|
761
|
+
cwd=cwd,
|
|
762
|
+
source_kind=source_kind or "exec",
|
|
763
|
+
source_path=str(path),
|
|
764
|
+
archived=archived,
|
|
765
|
+
missing=False,
|
|
766
|
+
preview=preview,
|
|
767
|
+
last_user_text=last_user_text,
|
|
768
|
+
last_assistant_text=last_assistant_text,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
def _collect_history_log_summaries(self) -> dict[str, HistoricalSessionSummary]:
|
|
772
|
+
collected: dict[str, HistoricalSessionSummary] = {}
|
|
773
|
+
for path, archived in self._iter_history_files():
|
|
774
|
+
summary = self._parse_history_session_file(
|
|
775
|
+
path,
|
|
776
|
+
archived=archived,
|
|
777
|
+
indexed=None,
|
|
778
|
+
)
|
|
779
|
+
if summary is None:
|
|
780
|
+
continue
|
|
781
|
+
existing = collected.get(summary.session_id)
|
|
782
|
+
if existing is None or (existing.archived and not summary.archived):
|
|
783
|
+
collected[summary.session_id] = summary
|
|
784
|
+
return collected
|
|
785
|
+
|
|
786
|
+
def _enrich_history_summary_from_log(
|
|
787
|
+
self,
|
|
788
|
+
summary: HistoricalSessionSummary,
|
|
789
|
+
log_summary: HistoricalSessionSummary | None,
|
|
790
|
+
) -> HistoricalSessionSummary:
|
|
791
|
+
if log_summary is None:
|
|
792
|
+
return summary
|
|
793
|
+
|
|
794
|
+
if summary.cwd is None:
|
|
795
|
+
summary.cwd = log_summary.cwd
|
|
796
|
+
if summary.source_kind is None:
|
|
797
|
+
summary.source_kind = log_summary.source_kind
|
|
798
|
+
summary.source_path = log_summary.source_path
|
|
799
|
+
summary.archived = log_summary.archived
|
|
800
|
+
summary.missing = log_summary.missing
|
|
801
|
+
summary.last_user_text = log_summary.last_user_text
|
|
802
|
+
summary.last_assistant_text = log_summary.last_assistant_text
|
|
803
|
+
if log_summary.last_assistant_text or log_summary.last_user_text:
|
|
804
|
+
summary.preview = (
|
|
805
|
+
log_summary.last_assistant_text or log_summary.last_user_text
|
|
806
|
+
)
|
|
807
|
+
elif summary.preview is None and log_summary.preview is not None:
|
|
808
|
+
summary.preview = log_summary.preview
|
|
809
|
+
return summary
|
|
810
|
+
|
|
811
|
+
def _iter_history_files(self) -> list[tuple[Path, bool]]:
|
|
812
|
+
files: list[tuple[Path, bool]] = []
|
|
813
|
+
if self.settings.sessions_dir.exists():
|
|
814
|
+
files.extend(
|
|
815
|
+
(path, False) for path in self.settings.sessions_dir.rglob("*.jsonl")
|
|
816
|
+
)
|
|
817
|
+
if self.settings.archived_sessions_dir.exists():
|
|
818
|
+
files.extend(
|
|
819
|
+
(path, True)
|
|
820
|
+
for path in self.settings.archived_sessions_dir.rglob("*.jsonl")
|
|
821
|
+
)
|
|
822
|
+
return sorted(files, key=lambda item: str(item[0]))
|
|
823
|
+
|
|
824
|
+
def _collect_exec_history_sessions(
|
|
825
|
+
self,
|
|
826
|
+
index_entries: dict[str, tuple[str, str]],
|
|
827
|
+
) -> list[HistoricalSessionSummary]:
|
|
828
|
+
collected = self._collect_history_log_summaries()
|
|
829
|
+
for summary in collected.values():
|
|
830
|
+
indexed = index_entries.get(summary.session_id)
|
|
831
|
+
if indexed is not None:
|
|
832
|
+
summary.thread_name = indexed[0]
|
|
833
|
+
summary.updated_at = indexed[1]
|
|
834
|
+
if summary.last_user_text is None and summary.last_assistant_text is None:
|
|
835
|
+
summary.preview = summary.thread_name
|
|
836
|
+
|
|
837
|
+
for session_id, (thread_name, updated_at) in index_entries.items():
|
|
838
|
+
if session_id in collected:
|
|
839
|
+
continue
|
|
840
|
+
collected[session_id] = HistoricalSessionSummary(
|
|
841
|
+
session_id=session_id,
|
|
842
|
+
thread_name=thread_name,
|
|
843
|
+
updated_at=updated_at,
|
|
844
|
+
kind="exec",
|
|
845
|
+
source_kind="exec",
|
|
846
|
+
missing=True,
|
|
847
|
+
preview=thread_name,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
return sorted(
|
|
851
|
+
collected.values(),
|
|
852
|
+
key=lambda session: session.updated_at,
|
|
853
|
+
reverse=True,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
async def _load_native_history_sessions(self) -> list[HistoricalSessionSummary]:
|
|
857
|
+
if self.native_client is None:
|
|
858
|
+
return []
|
|
859
|
+
history_logs = self._collect_history_log_summaries()
|
|
860
|
+
client = self._spawn_native_client()
|
|
861
|
+
try:
|
|
862
|
+
threads = await client.list_threads()
|
|
863
|
+
except Exception:
|
|
864
|
+
return []
|
|
865
|
+
finally:
|
|
866
|
+
await self._close_native_runner(client)
|
|
867
|
+
entries = []
|
|
868
|
+
for thread in threads:
|
|
869
|
+
entry = HistoricalSessionSummary(
|
|
870
|
+
session_id=thread.thread_id,
|
|
871
|
+
thread_name=thread.thread_name,
|
|
872
|
+
updated_at=thread.updated_at,
|
|
873
|
+
kind="native",
|
|
874
|
+
cwd=thread.cwd,
|
|
875
|
+
source_kind=thread.source_kind,
|
|
876
|
+
preview=thread.preview,
|
|
877
|
+
)
|
|
878
|
+
entries.append(
|
|
879
|
+
self._enrich_history_summary_from_log(
|
|
880
|
+
entry,
|
|
881
|
+
history_logs.get(thread.thread_id),
|
|
882
|
+
)
|
|
883
|
+
)
|
|
884
|
+
return sorted(entries, key=lambda session: session.updated_at, reverse=True)
|
|
885
|
+
|
|
886
|
+
def _get_native_history_sessions(self) -> list[HistoricalSessionSummary]:
|
|
887
|
+
if self.native_client is None:
|
|
888
|
+
return []
|
|
889
|
+
if not self._native_history_loaded:
|
|
890
|
+
try:
|
|
891
|
+
asyncio.get_running_loop()
|
|
892
|
+
except RuntimeError:
|
|
893
|
+
self._native_history_entries = asyncio.run(
|
|
894
|
+
self._load_native_history_sessions()
|
|
895
|
+
)
|
|
896
|
+
self._native_history_loaded = True
|
|
897
|
+
else:
|
|
898
|
+
return list(self._native_history_entries)
|
|
899
|
+
return list(self._native_history_entries)
|
|
900
|
+
|
|
901
|
+
async def refresh_history_sessions(self) -> list[HistoricalSessionSummary]:
|
|
902
|
+
self._native_history_entries = await self._load_native_history_sessions()
|
|
903
|
+
self._native_history_loaded = True
|
|
904
|
+
return self.list_history_sessions()
|
|
905
|
+
|
|
906
|
+
def list_history_sessions(self) -> list[HistoricalSessionSummary]:
|
|
907
|
+
index_entries, has_index = self._load_history_index()
|
|
908
|
+
native_entries = self._get_native_history_sessions()
|
|
909
|
+
native_ids = {entry.session_id for entry in native_entries}
|
|
910
|
+
exec_entries = [
|
|
911
|
+
entry
|
|
912
|
+
for entry in self._collect_exec_history_sessions(index_entries)
|
|
913
|
+
if entry.session_id not in native_ids
|
|
914
|
+
]
|
|
915
|
+
if native_entries or exec_entries:
|
|
916
|
+
return native_entries + exec_entries
|
|
917
|
+
if not has_index:
|
|
918
|
+
raise ValueError("未找到 Codex 历史会话索引。")
|
|
919
|
+
raise ValueError("未找到 Codex 历史会话。")
|
|
920
|
+
|
|
921
|
+
def get_history_session(self, session_id: str) -> HistoricalSessionSummary:
|
|
922
|
+
for session in self.list_history_sessions():
|
|
923
|
+
if session.session_id == session_id:
|
|
924
|
+
return session
|
|
925
|
+
raise ValueError("未找到指定历史会话。")
|
|
926
|
+
|
|
927
|
+
def _history_total_pages(self, entries: list[HistoricalSessionSummary]) -> int:
|
|
928
|
+
return max(1, (len(entries) + HISTORY_PAGE_SIZE - 1) // HISTORY_PAGE_SIZE)
|
|
929
|
+
|
|
930
|
+
def _clamp_history_page(
|
|
931
|
+
self, entries: list[HistoricalSessionSummary], page: int
|
|
932
|
+
) -> int:
|
|
933
|
+
total_pages = self._history_total_pages(entries)
|
|
934
|
+
return max(0, min(page, total_pages - 1))
|
|
935
|
+
|
|
936
|
+
def _history_entries_for_scope(self, scope: str) -> list[HistoricalSessionSummary]:
|
|
937
|
+
entries = self.list_history_sessions()
|
|
938
|
+
if scope == "menu":
|
|
939
|
+
return entries
|
|
940
|
+
if scope == "resume":
|
|
941
|
+
return [entry for entry in entries if entry.kind == "native"]
|
|
942
|
+
if scope == "exec":
|
|
943
|
+
return [entry for entry in entries if entry.kind == "exec"]
|
|
944
|
+
raise ValueError("未知历史会话模式。")
|
|
945
|
+
|
|
946
|
+
def _replace_history_browser_state(
|
|
947
|
+
self,
|
|
948
|
+
chat_key: str,
|
|
949
|
+
*,
|
|
950
|
+
page: int,
|
|
951
|
+
scope: str = "menu",
|
|
952
|
+
selected_session_id: str | None = None,
|
|
953
|
+
previous: HistoryBrowserState | None = None,
|
|
954
|
+
) -> HistoryBrowserState:
|
|
955
|
+
entries = self._history_entries_for_scope(scope)
|
|
956
|
+
state = HistoryBrowserState(
|
|
957
|
+
chat_key=chat_key,
|
|
958
|
+
page=self._clamp_history_page(entries, page),
|
|
959
|
+
token=previous.token if previous else self._make_browser_token(),
|
|
960
|
+
version=(previous.version + 1) if previous else 1,
|
|
961
|
+
entries=entries,
|
|
962
|
+
scope=scope,
|
|
963
|
+
selected_session_id=selected_session_id,
|
|
964
|
+
message_id=previous.message_id if previous else None,
|
|
965
|
+
)
|
|
966
|
+
self.history_browsers[chat_key] = state
|
|
967
|
+
return state
|
|
968
|
+
|
|
969
|
+
def open_history_browser(self, chat_key: str) -> HistoryBrowserState:
|
|
970
|
+
return self._replace_history_browser_state(chat_key, page=0, scope="menu")
|
|
971
|
+
|
|
972
|
+
def get_history_browser(
|
|
973
|
+
self,
|
|
974
|
+
chat_key: str,
|
|
975
|
+
token: str | None = None,
|
|
976
|
+
version: int | None = None,
|
|
977
|
+
) -> HistoryBrowserState:
|
|
978
|
+
state = self.history_browsers.get(chat_key)
|
|
979
|
+
if state is None:
|
|
980
|
+
raise ValueError(HISTORY_STALE_MESSAGE)
|
|
981
|
+
if token is not None and state.token != token:
|
|
982
|
+
raise ValueError(HISTORY_STALE_MESSAGE)
|
|
983
|
+
if version is not None and state.version != version:
|
|
984
|
+
raise ValueError(HISTORY_STALE_MESSAGE)
|
|
985
|
+
return state
|
|
986
|
+
|
|
987
|
+
def remember_history_browser_message(
|
|
988
|
+
self,
|
|
989
|
+
chat_key: str,
|
|
990
|
+
token: str,
|
|
991
|
+
message_id: int | None,
|
|
992
|
+
) -> None:
|
|
993
|
+
if message_id is None:
|
|
994
|
+
return
|
|
995
|
+
browser = self.get_history_browser(chat_key, token=token)
|
|
996
|
+
browser.message_id = message_id
|
|
997
|
+
|
|
998
|
+
def close_history_browser(self, chat_key: str, token: str, version: int) -> None:
|
|
999
|
+
self.get_history_browser(chat_key, token=token, version=version)
|
|
1000
|
+
self.history_browsers.pop(chat_key, None)
|
|
1001
|
+
|
|
1002
|
+
def navigate_history_browser(
|
|
1003
|
+
self,
|
|
1004
|
+
chat_key: str,
|
|
1005
|
+
token: str,
|
|
1006
|
+
version: int,
|
|
1007
|
+
action: str,
|
|
1008
|
+
index: int | None = None,
|
|
1009
|
+
) -> HistoryBrowserState:
|
|
1010
|
+
browser = self.get_history_browser(chat_key, token=token, version=version)
|
|
1011
|
+
if action == "scope_resume":
|
|
1012
|
+
return self._replace_history_browser_state(
|
|
1013
|
+
chat_key,
|
|
1014
|
+
page=0,
|
|
1015
|
+
scope="resume",
|
|
1016
|
+
previous=browser,
|
|
1017
|
+
)
|
|
1018
|
+
if action == "scope_exec":
|
|
1019
|
+
return self._replace_history_browser_state(
|
|
1020
|
+
chat_key,
|
|
1021
|
+
page=0,
|
|
1022
|
+
scope="exec",
|
|
1023
|
+
previous=browser,
|
|
1024
|
+
)
|
|
1025
|
+
if action == "menu":
|
|
1026
|
+
return self._replace_history_browser_state(
|
|
1027
|
+
chat_key,
|
|
1028
|
+
page=0,
|
|
1029
|
+
scope="menu",
|
|
1030
|
+
previous=browser,
|
|
1031
|
+
)
|
|
1032
|
+
if action == "open":
|
|
1033
|
+
if index is None or not 0 <= index < len(browser.entries):
|
|
1034
|
+
raise ValueError("历史会话不存在。")
|
|
1035
|
+
return self._replace_history_browser_state(
|
|
1036
|
+
chat_key,
|
|
1037
|
+
page=browser.page,
|
|
1038
|
+
scope=browser.scope,
|
|
1039
|
+
selected_session_id=browser.entries[index].session_id,
|
|
1040
|
+
previous=browser,
|
|
1041
|
+
)
|
|
1042
|
+
if action == "back":
|
|
1043
|
+
return self._replace_history_browser_state(
|
|
1044
|
+
chat_key,
|
|
1045
|
+
page=browser.page,
|
|
1046
|
+
scope=browser.scope,
|
|
1047
|
+
previous=browser,
|
|
1048
|
+
)
|
|
1049
|
+
if action == "refresh":
|
|
1050
|
+
return self._replace_history_browser_state(
|
|
1051
|
+
chat_key,
|
|
1052
|
+
page=browser.page,
|
|
1053
|
+
scope=browser.scope,
|
|
1054
|
+
selected_session_id=browser.selected_session_id,
|
|
1055
|
+
previous=browser,
|
|
1056
|
+
)
|
|
1057
|
+
if action == "prev":
|
|
1058
|
+
return self._replace_history_browser_state(
|
|
1059
|
+
chat_key,
|
|
1060
|
+
page=browser.page - 1,
|
|
1061
|
+
scope=browser.scope,
|
|
1062
|
+
previous=browser,
|
|
1063
|
+
)
|
|
1064
|
+
if action == "next":
|
|
1065
|
+
return self._replace_history_browser_state(
|
|
1066
|
+
chat_key,
|
|
1067
|
+
page=browser.page + 1,
|
|
1068
|
+
scope=browser.scope,
|
|
1069
|
+
previous=browser,
|
|
1070
|
+
)
|
|
1071
|
+
raise ValueError("未知历史会话操作。")
|
|
1072
|
+
|
|
1073
|
+
def render_history_browser(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]:
|
|
1074
|
+
browser = self.get_history_browser(chat_key)
|
|
1075
|
+
preferences = self.get_preferences(chat_key)
|
|
1076
|
+
session = self.sessions.get(chat_key)
|
|
1077
|
+
current_mode = session.active_mode if session else preferences.default_mode
|
|
1078
|
+
if session is None:
|
|
1079
|
+
current_thread = "未绑定"
|
|
1080
|
+
elif current_mode == "exec":
|
|
1081
|
+
current_thread = self._current_exec_thread_id(session) or "未绑定"
|
|
1082
|
+
elif self.native_client is not None:
|
|
1083
|
+
current_thread = session.native_thread_id or "未绑定"
|
|
1084
|
+
else:
|
|
1085
|
+
current_thread = session.thread_id or "未绑定"
|
|
1086
|
+
|
|
1087
|
+
if browser.scope == "menu":
|
|
1088
|
+
resume_count = sum(1 for entry in browser.entries if entry.kind == "native")
|
|
1089
|
+
exec_count = sum(1 for entry in browser.entries if entry.kind == "exec")
|
|
1090
|
+
lines = [
|
|
1091
|
+
"Codex 历史会话",
|
|
1092
|
+
f"当前模式:{current_mode}",
|
|
1093
|
+
f"当前绑定:{current_thread}",
|
|
1094
|
+
f"当前工作目录:{preferences.workdir}",
|
|
1095
|
+
f"resume:{resume_count}",
|
|
1096
|
+
f"exec:{exec_count}",
|
|
1097
|
+
]
|
|
1098
|
+
keyboard = [
|
|
1099
|
+
[
|
|
1100
|
+
InlineKeyboardButton(
|
|
1101
|
+
text=f"resume ({resume_count})",
|
|
1102
|
+
callback_data=encode_history_callback(
|
|
1103
|
+
browser.token,
|
|
1104
|
+
browser.version,
|
|
1105
|
+
"scope_resume",
|
|
1106
|
+
),
|
|
1107
|
+
)
|
|
1108
|
+
],
|
|
1109
|
+
[
|
|
1110
|
+
InlineKeyboardButton(
|
|
1111
|
+
text=f"exec ({exec_count})",
|
|
1112
|
+
callback_data=encode_history_callback(
|
|
1113
|
+
browser.token,
|
|
1114
|
+
browser.version,
|
|
1115
|
+
"scope_exec",
|
|
1116
|
+
),
|
|
1117
|
+
)
|
|
1118
|
+
],
|
|
1119
|
+
[
|
|
1120
|
+
InlineKeyboardButton(
|
|
1121
|
+
text="关闭",
|
|
1122
|
+
callback_data=encode_history_callback(
|
|
1123
|
+
browser.token,
|
|
1124
|
+
browser.version,
|
|
1125
|
+
"close",
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
],
|
|
1129
|
+
]
|
|
1130
|
+
return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
1131
|
+
|
|
1132
|
+
if browser.selected_session_id is not None:
|
|
1133
|
+
selected = next(
|
|
1134
|
+
(
|
|
1135
|
+
entry
|
|
1136
|
+
for entry in browser.entries
|
|
1137
|
+
if entry.session_id == browser.selected_session_id
|
|
1138
|
+
),
|
|
1139
|
+
None,
|
|
1140
|
+
)
|
|
1141
|
+
if selected is None:
|
|
1142
|
+
raise ValueError("未找到指定历史会话。")
|
|
1143
|
+
lines = [
|
|
1144
|
+
"Codex 历史会话",
|
|
1145
|
+
f"类型:{selected.kind}",
|
|
1146
|
+
f"标题:{selected.thread_name}",
|
|
1147
|
+
f"更新时间:{self._format_history_local_time(selected.updated_at)}",
|
|
1148
|
+
f"原始工作目录:{selected.cwd or '未知'}",
|
|
1149
|
+
f"归档:{'是' if selected.archived else '否'}",
|
|
1150
|
+
f"上次对话概览:{selected.preview or selected.thread_name}",
|
|
1151
|
+
]
|
|
1152
|
+
if selected.last_user_text:
|
|
1153
|
+
lines.append(f"上次用户输入:{selected.last_user_text}")
|
|
1154
|
+
if selected.last_assistant_text:
|
|
1155
|
+
lines.append(f"上次助手回复:{selected.last_assistant_text}")
|
|
1156
|
+
if selected.missing:
|
|
1157
|
+
lines.append("源会话文件缺失,无法继续该对话。")
|
|
1158
|
+
can_continue = not (
|
|
1159
|
+
selected.kind == "exec"
|
|
1160
|
+
and (selected.missing or selected.source_path is None)
|
|
1161
|
+
)
|
|
1162
|
+
keyboard: list[list[InlineKeyboardButton]] = []
|
|
1163
|
+
if can_continue:
|
|
1164
|
+
keyboard.append(
|
|
1165
|
+
[
|
|
1166
|
+
InlineKeyboardButton(
|
|
1167
|
+
text="续聊",
|
|
1168
|
+
callback_data=encode_history_callback(
|
|
1169
|
+
browser.token,
|
|
1170
|
+
browser.version,
|
|
1171
|
+
"apply",
|
|
1172
|
+
),
|
|
1173
|
+
)
|
|
1174
|
+
]
|
|
1175
|
+
)
|
|
1176
|
+
keyboard.append(
|
|
1177
|
+
[
|
|
1178
|
+
InlineKeyboardButton(
|
|
1179
|
+
text="返回列表",
|
|
1180
|
+
callback_data=encode_history_callback(
|
|
1181
|
+
browser.token,
|
|
1182
|
+
browser.version,
|
|
1183
|
+
"back",
|
|
1184
|
+
),
|
|
1185
|
+
),
|
|
1186
|
+
InlineKeyboardButton(
|
|
1187
|
+
text="返回模式选择",
|
|
1188
|
+
callback_data=encode_history_callback(
|
|
1189
|
+
browser.token,
|
|
1190
|
+
browser.version,
|
|
1191
|
+
"menu",
|
|
1192
|
+
),
|
|
1193
|
+
),
|
|
1194
|
+
InlineKeyboardButton(
|
|
1195
|
+
text="关闭",
|
|
1196
|
+
callback_data=encode_history_callback(
|
|
1197
|
+
browser.token,
|
|
1198
|
+
browser.version,
|
|
1199
|
+
"close",
|
|
1200
|
+
),
|
|
1201
|
+
),
|
|
1202
|
+
]
|
|
1203
|
+
)
|
|
1204
|
+
return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
1205
|
+
|
|
1206
|
+
total_pages = self._history_total_pages(browser.entries)
|
|
1207
|
+
start = browser.page * HISTORY_PAGE_SIZE
|
|
1208
|
+
end = start + HISTORY_PAGE_SIZE
|
|
1209
|
+
current_entries = browser.entries[start:end]
|
|
1210
|
+
lines = [
|
|
1211
|
+
"Codex 历史会话",
|
|
1212
|
+
f"当前浏览模式:{browser.scope}",
|
|
1213
|
+
f"当前模式:{current_mode}",
|
|
1214
|
+
f"当前绑定:{current_thread}",
|
|
1215
|
+
f"当前工作目录:{preferences.workdir}",
|
|
1216
|
+
f"总数:{len(browser.entries)}",
|
|
1217
|
+
f"第 {browser.page + 1}/{total_pages} 页",
|
|
1218
|
+
]
|
|
1219
|
+
keyboard: list[list[InlineKeyboardButton]] = []
|
|
1220
|
+
for offset, entry in enumerate(current_entries):
|
|
1221
|
+
keyboard.append(
|
|
1222
|
+
[
|
|
1223
|
+
InlineKeyboardButton(
|
|
1224
|
+
text=(
|
|
1225
|
+
f"{entry.thread_name} | "
|
|
1226
|
+
f"{self._format_history_relative_time(entry.updated_at)}"
|
|
1227
|
+
),
|
|
1228
|
+
callback_data=encode_history_callback(
|
|
1229
|
+
browser.token,
|
|
1230
|
+
browser.version,
|
|
1231
|
+
"open",
|
|
1232
|
+
start + offset,
|
|
1233
|
+
),
|
|
1234
|
+
)
|
|
1235
|
+
]
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
nav_buttons: list[InlineKeyboardButton] = []
|
|
1239
|
+
if browser.page > 0:
|
|
1240
|
+
nav_buttons.append(
|
|
1241
|
+
InlineKeyboardButton(
|
|
1242
|
+
text="上一页",
|
|
1243
|
+
callback_data=encode_history_callback(
|
|
1244
|
+
browser.token,
|
|
1245
|
+
browser.version,
|
|
1246
|
+
"prev",
|
|
1247
|
+
),
|
|
1248
|
+
)
|
|
1249
|
+
)
|
|
1250
|
+
if browser.page + 1 < total_pages:
|
|
1251
|
+
nav_buttons.append(
|
|
1252
|
+
InlineKeyboardButton(
|
|
1253
|
+
text="下一页",
|
|
1254
|
+
callback_data=encode_history_callback(
|
|
1255
|
+
browser.token,
|
|
1256
|
+
browser.version,
|
|
1257
|
+
"next",
|
|
1258
|
+
),
|
|
1259
|
+
)
|
|
1260
|
+
)
|
|
1261
|
+
if nav_buttons:
|
|
1262
|
+
keyboard.append(nav_buttons)
|
|
1263
|
+
|
|
1264
|
+
keyboard.append(
|
|
1265
|
+
[
|
|
1266
|
+
InlineKeyboardButton(
|
|
1267
|
+
text="返回模式选择",
|
|
1268
|
+
callback_data=encode_history_callback(
|
|
1269
|
+
browser.token,
|
|
1270
|
+
browser.version,
|
|
1271
|
+
"menu",
|
|
1272
|
+
),
|
|
1273
|
+
),
|
|
1274
|
+
InlineKeyboardButton(
|
|
1275
|
+
text="刷新",
|
|
1276
|
+
callback_data=encode_history_callback(
|
|
1277
|
+
browser.token,
|
|
1278
|
+
browser.version,
|
|
1279
|
+
"refresh",
|
|
1280
|
+
),
|
|
1281
|
+
),
|
|
1282
|
+
InlineKeyboardButton(
|
|
1283
|
+
text="关闭",
|
|
1284
|
+
callback_data=encode_history_callback(
|
|
1285
|
+
browser.token,
|
|
1286
|
+
browser.version,
|
|
1287
|
+
"close",
|
|
1288
|
+
),
|
|
1289
|
+
),
|
|
1290
|
+
]
|
|
1291
|
+
)
|
|
1292
|
+
return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
1293
|
+
|
|
1294
|
+
async def apply_history_session(self, chat_key: str, token: str, version: int) -> str:
|
|
1295
|
+
self._ensure_not_running(chat_key)
|
|
1296
|
+
browser = self.get_history_browser(chat_key, token=token, version=version)
|
|
1297
|
+
if browser.selected_session_id is None:
|
|
1298
|
+
raise ValueError("请先选择一个历史会话。")
|
|
1299
|
+
|
|
1300
|
+
selected = next(
|
|
1301
|
+
(
|
|
1302
|
+
entry
|
|
1303
|
+
for entry in browser.entries
|
|
1304
|
+
if entry.session_id == browser.selected_session_id
|
|
1305
|
+
),
|
|
1306
|
+
None,
|
|
1307
|
+
)
|
|
1308
|
+
if selected is None:
|
|
1309
|
+
raise ValueError("未找到指定历史会话。")
|
|
1310
|
+
if selected.kind == "exec" and (selected.missing or selected.source_path is None):
|
|
1311
|
+
raise ValueError("源会话文件不存在,无法继续。")
|
|
1312
|
+
|
|
1313
|
+
session = self.activate_chat(chat_key)
|
|
1314
|
+
if selected.kind == "native":
|
|
1315
|
+
session.active_mode = "resume"
|
|
1316
|
+
self._set_native_thread_id(session, selected.session_id)
|
|
1317
|
+
else:
|
|
1318
|
+
session.active_mode = "exec"
|
|
1319
|
+
self._set_exec_thread_id(session, selected.session_id)
|
|
1320
|
+
self._sync_legacy_thread_id(session)
|
|
1321
|
+
session.strict_resume = True
|
|
1322
|
+
|
|
1323
|
+
current = self.get_preferences(chat_key)
|
|
1324
|
+
notice_lines = [
|
|
1325
|
+
f"已切换到历史会话({selected.kind}):{selected.thread_name}",
|
|
1326
|
+
f"当前模式:{'resume' if selected.kind == 'native' else 'exec'}",
|
|
1327
|
+
]
|
|
1328
|
+
if selected.cwd:
|
|
1329
|
+
target = Path(selected.cwd).expanduser()
|
|
1330
|
+
if target.exists() and target.is_dir():
|
|
1331
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
1332
|
+
model=current.model,
|
|
1333
|
+
reasoning_effort=current.reasoning_effort,
|
|
1334
|
+
permission_mode=current.permission_mode,
|
|
1335
|
+
workdir=str(target.resolve()),
|
|
1336
|
+
default_mode=current.default_mode,
|
|
1337
|
+
)
|
|
1338
|
+
self._persist_preferences()
|
|
1339
|
+
else:
|
|
1340
|
+
notice_lines.append("原工作目录不存在,已保留当前工作目录。")
|
|
1341
|
+
notice_lines.append(f"当前工作目录:{self.get_preferences(chat_key).workdir}")
|
|
1342
|
+
notice_lines.append("下一条普通消息会继续该会话。")
|
|
1343
|
+
return "\n".join(notice_lines)
|
|
1344
|
+
|
|
1345
|
+
def load_models(self) -> dict[str, ModelInfo]:
|
|
1346
|
+
try:
|
|
1347
|
+
payload = json.loads(
|
|
1348
|
+
self.settings.models_cache_path.read_text(encoding="utf-8")
|
|
1349
|
+
)
|
|
1350
|
+
except FileNotFoundError as exc:
|
|
1351
|
+
raise FileNotFoundError("未找到 Codex 模型缓存文件。") from exc
|
|
1352
|
+
except json.JSONDecodeError as exc:
|
|
1353
|
+
raise ValueError("Codex 模型缓存文件损坏,无法解析。") from exc
|
|
1354
|
+
|
|
1355
|
+
models = payload.get("models")
|
|
1356
|
+
if not isinstance(models, list):
|
|
1357
|
+
raise ValueError("Codex 模型缓存文件格式不正确。")
|
|
1358
|
+
|
|
1359
|
+
parsed: dict[str, ModelInfo] = {}
|
|
1360
|
+
for item in models:
|
|
1361
|
+
if not isinstance(item, dict):
|
|
1362
|
+
continue
|
|
1363
|
+
slug = item.get("slug")
|
|
1364
|
+
if not isinstance(slug, str) or not slug:
|
|
1365
|
+
continue
|
|
1366
|
+
supported = [
|
|
1367
|
+
level.get("effort")
|
|
1368
|
+
for level in item.get("supported_reasoning_levels", [])
|
|
1369
|
+
if isinstance(level, dict) and isinstance(level.get("effort"), str)
|
|
1370
|
+
]
|
|
1371
|
+
parsed[slug] = ModelInfo(
|
|
1372
|
+
slug=slug,
|
|
1373
|
+
display_name=str(item.get("display_name") or slug),
|
|
1374
|
+
visibility=str(item.get("visibility") or ""),
|
|
1375
|
+
priority=int(item.get("priority") or 0),
|
|
1376
|
+
default_reasoning_level=str(
|
|
1377
|
+
item.get("default_reasoning_level") or "medium"
|
|
1378
|
+
),
|
|
1379
|
+
supported_reasoning_levels=supported,
|
|
1380
|
+
)
|
|
1381
|
+
if not parsed:
|
|
1382
|
+
raise ValueError("Codex 模型缓存中没有可用模型。")
|
|
1383
|
+
return parsed
|
|
1384
|
+
|
|
1385
|
+
def list_models(self) -> list[ModelInfo]:
|
|
1386
|
+
visible = [
|
|
1387
|
+
model
|
|
1388
|
+
for model in self.load_models().values()
|
|
1389
|
+
if model.visibility == VISIBLE_MODEL
|
|
1390
|
+
]
|
|
1391
|
+
return sorted(visible, key=lambda model: (model.priority, model.slug))
|
|
1392
|
+
|
|
1393
|
+
def _load_preferences(self) -> dict[str, ChatPreferences]:
|
|
1394
|
+
path = self.settings.preferences_path
|
|
1395
|
+
if not path.exists():
|
|
1396
|
+
return {}
|
|
1397
|
+
try:
|
|
1398
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
1399
|
+
except (OSError, json.JSONDecodeError):
|
|
1400
|
+
return {}
|
|
1401
|
+
if not isinstance(raw, dict):
|
|
1402
|
+
return {}
|
|
1403
|
+
loaded: dict[str, ChatPreferences] = {}
|
|
1404
|
+
for chat_key, value in raw.items():
|
|
1405
|
+
if not isinstance(chat_key, str) or not isinstance(value, dict):
|
|
1406
|
+
continue
|
|
1407
|
+
model = value.get("model")
|
|
1408
|
+
reasoning_effort = value.get("reasoning_effort")
|
|
1409
|
+
permission_mode = value.get("permission_mode")
|
|
1410
|
+
workdir = value.get("workdir")
|
|
1411
|
+
default_mode = value.get("default_mode")
|
|
1412
|
+
if not all(
|
|
1413
|
+
isinstance(field, str)
|
|
1414
|
+
for field in (model, reasoning_effort, permission_mode)
|
|
1415
|
+
):
|
|
1416
|
+
continue
|
|
1417
|
+
loaded[chat_key] = ChatPreferences(
|
|
1418
|
+
model=model,
|
|
1419
|
+
reasoning_effort=reasoning_effort,
|
|
1420
|
+
permission_mode=permission_mode,
|
|
1421
|
+
workdir=(
|
|
1422
|
+
workdir if isinstance(workdir, str) and workdir else str(Path.home())
|
|
1423
|
+
),
|
|
1424
|
+
default_mode=(
|
|
1425
|
+
default_mode
|
|
1426
|
+
if isinstance(default_mode, str)
|
|
1427
|
+
and default_mode in {"resume", "exec"}
|
|
1428
|
+
else "resume"
|
|
1429
|
+
),
|
|
1430
|
+
)
|
|
1431
|
+
return loaded
|
|
1432
|
+
|
|
1433
|
+
def _persist_preferences(self) -> None:
|
|
1434
|
+
self.settings.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1435
|
+
serialized = {
|
|
1436
|
+
chat_key: asdict(preferences)
|
|
1437
|
+
for chat_key, preferences in self.preference_overrides.items()
|
|
1438
|
+
}
|
|
1439
|
+
self.settings.preferences_path.write_text(
|
|
1440
|
+
json.dumps(serialized, ensure_ascii=False, indent=2),
|
|
1441
|
+
encoding="utf-8",
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
def _load_codex_defaults(self) -> tuple[str | None, str | None]:
|
|
1445
|
+
path = self.settings.codex_config_path
|
|
1446
|
+
if not path.exists():
|
|
1447
|
+
return None, None
|
|
1448
|
+
try:
|
|
1449
|
+
config = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
1450
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
1451
|
+
return None, None
|
|
1452
|
+
model = config.get("model")
|
|
1453
|
+
effort = config.get("model_reasoning_effort")
|
|
1454
|
+
return (
|
|
1455
|
+
model if isinstance(model, str) else None,
|
|
1456
|
+
effort if isinstance(effort, str) else None,
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
def _pick_default_model(self, models: dict[str, ModelInfo]) -> ModelInfo:
|
|
1460
|
+
configured_model, _ = self._load_codex_defaults()
|
|
1461
|
+
if configured_model and configured_model in models:
|
|
1462
|
+
return models[configured_model]
|
|
1463
|
+
visible = [
|
|
1464
|
+
model for model in models.values() if model.visibility == VISIBLE_MODEL
|
|
1465
|
+
]
|
|
1466
|
+
ranked = sorted(
|
|
1467
|
+
visible or list(models.values()),
|
|
1468
|
+
key=lambda model: (model.priority, model.slug),
|
|
1469
|
+
)
|
|
1470
|
+
return ranked[0]
|
|
1471
|
+
|
|
1472
|
+
def _normalize_effort(self, model: ModelInfo, effort: str | None) -> str:
|
|
1473
|
+
supported = set(model.supported_reasoning_levels)
|
|
1474
|
+
if effort and effort in supported:
|
|
1475
|
+
return effort
|
|
1476
|
+
if "high" in supported:
|
|
1477
|
+
return "high"
|
|
1478
|
+
if model.default_reasoning_level in supported:
|
|
1479
|
+
return model.default_reasoning_level
|
|
1480
|
+
if model.supported_reasoning_levels:
|
|
1481
|
+
return model.supported_reasoning_levels[0]
|
|
1482
|
+
return model.default_reasoning_level
|
|
1483
|
+
|
|
1484
|
+
def _default_preferences(self) -> ChatPreferences:
|
|
1485
|
+
models = self.load_models()
|
|
1486
|
+
model = self._pick_default_model(models)
|
|
1487
|
+
_, configured_effort = self._load_codex_defaults()
|
|
1488
|
+
effort = self._normalize_effort(model, configured_effort)
|
|
1489
|
+
return ChatPreferences(
|
|
1490
|
+
model=model.slug,
|
|
1491
|
+
reasoning_effort=effort,
|
|
1492
|
+
permission_mode="safe",
|
|
1493
|
+
workdir=str(Path.home()),
|
|
1494
|
+
default_mode="resume",
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
def get_session(self, chat_key: str) -> ChatSession:
|
|
1498
|
+
return self.sessions.setdefault(chat_key, ChatSession())
|
|
1499
|
+
|
|
1500
|
+
def get_preferences(self, chat_key: str) -> ChatPreferences:
|
|
1501
|
+
preferences = self.preference_overrides.get(chat_key)
|
|
1502
|
+
if preferences is None:
|
|
1503
|
+
preferences = self._default_preferences()
|
|
1504
|
+
self.preference_overrides[chat_key] = preferences
|
|
1505
|
+
self._persist_preferences()
|
|
1506
|
+
return preferences
|
|
1507
|
+
|
|
1508
|
+
def describe_preferences(self, chat_key: str) -> str:
|
|
1509
|
+
return format_preferences_summary(self.get_preferences(chat_key))
|
|
1510
|
+
|
|
1511
|
+
def describe_workdir(self, chat_key: str) -> str:
|
|
1512
|
+
preferences = self.get_preferences(chat_key)
|
|
1513
|
+
session = self.sessions.get(chat_key)
|
|
1514
|
+
next_step = "继续当前会话" if session and session.thread_id else "新开会话"
|
|
1515
|
+
return (
|
|
1516
|
+
f"当前工作目录:{preferences.workdir}\n"
|
|
1517
|
+
f"当前设置:{format_preferences_summary(preferences)}\n"
|
|
1518
|
+
f"下一条普通消息:{next_step}"
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
def _make_browser_token(self) -> str:
|
|
1522
|
+
return secrets.token_hex(4)
|
|
1523
|
+
|
|
1524
|
+
def activate_chat(self, chat_key: str) -> ChatSession:
|
|
1525
|
+
session = self.get_session(chat_key)
|
|
1526
|
+
if not session.active or session.active_mode not in {"resume", "exec"}:
|
|
1527
|
+
session.active_mode = self.get_preferences(chat_key).default_mode
|
|
1528
|
+
session.active = True
|
|
1529
|
+
self._sync_legacy_thread_id(session)
|
|
1530
|
+
return session
|
|
1531
|
+
|
|
1532
|
+
def _ensure_not_running(self, chat_key: str) -> None:
|
|
1533
|
+
session = self.sessions.get(chat_key)
|
|
1534
|
+
if session and session.running:
|
|
1535
|
+
raise RuntimeError("Codex is already running for this chat")
|
|
1536
|
+
|
|
1537
|
+
def _sync_legacy_thread_id(self, session: ChatSession) -> None:
|
|
1538
|
+
if session.active_mode == "exec":
|
|
1539
|
+
session.thread_id = session.exec_thread_id or session.thread_id
|
|
1540
|
+
return
|
|
1541
|
+
if self.native_client is not None:
|
|
1542
|
+
session.thread_id = session.native_thread_id
|
|
1543
|
+
return
|
|
1544
|
+
session.thread_id = session.exec_thread_id or session.thread_id
|
|
1545
|
+
|
|
1546
|
+
def _current_exec_thread_id(self, session: ChatSession) -> str | None:
|
|
1547
|
+
return session.exec_thread_id or session.thread_id
|
|
1548
|
+
|
|
1549
|
+
def _set_exec_thread_id(self, session: ChatSession, thread_id: str | None) -> None:
|
|
1550
|
+
session.exec_thread_id = thread_id
|
|
1551
|
+
if session.active_mode == "exec" or self.native_client is None:
|
|
1552
|
+
session.thread_id = thread_id
|
|
1553
|
+
|
|
1554
|
+
def _set_native_thread_id(self, session: ChatSession, thread_id: str | None) -> None:
|
|
1555
|
+
session.native_thread_id = thread_id
|
|
1556
|
+
if session.active_mode == "resume":
|
|
1557
|
+
session.thread_id = thread_id
|
|
1558
|
+
|
|
1559
|
+
def _clear_thread_only(self, chat_key: str) -> None:
|
|
1560
|
+
session = self.get_session(chat_key)
|
|
1561
|
+
session.native_thread_id = None
|
|
1562
|
+
session.thread_id = None
|
|
1563
|
+
session.exec_thread_id = None
|
|
1564
|
+
session.strict_resume = False
|
|
1565
|
+
|
|
1566
|
+
def _browser_total_pages(self, entries: list[DirectoryEntry]) -> int:
|
|
1567
|
+
return max(1, (len(entries) + BROWSER_PAGE_SIZE - 1) // BROWSER_PAGE_SIZE)
|
|
1568
|
+
|
|
1569
|
+
def _clamp_browser_page(self, entries: list[DirectoryEntry], page: int) -> int:
|
|
1570
|
+
total_pages = self._browser_total_pages(entries)
|
|
1571
|
+
return max(0, min(page, total_pages - 1))
|
|
1572
|
+
|
|
1573
|
+
def _resolve_directory_path(self, chat_key: str, raw_path: str) -> str:
|
|
1574
|
+
base = Path(self.get_preferences(chat_key).workdir)
|
|
1575
|
+
candidate = Path(raw_path).expanduser()
|
|
1576
|
+
if not candidate.is_absolute():
|
|
1577
|
+
candidate = base / candidate
|
|
1578
|
+
resolved = candidate.resolve()
|
|
1579
|
+
if not resolved.exists():
|
|
1580
|
+
raise ValueError("目录不存在。")
|
|
1581
|
+
if not resolved.is_dir():
|
|
1582
|
+
raise ValueError("目标不是目录。")
|
|
1583
|
+
return str(resolved)
|
|
1584
|
+
|
|
1585
|
+
def _list_directory_entries(
|
|
1586
|
+
self,
|
|
1587
|
+
path: str,
|
|
1588
|
+
*,
|
|
1589
|
+
show_hidden: bool,
|
|
1590
|
+
) -> tuple[list[DirectoryEntry], list[str]]:
|
|
1591
|
+
directory = Path(path)
|
|
1592
|
+
try:
|
|
1593
|
+
children = list(directory.iterdir())
|
|
1594
|
+
except OSError as exc:
|
|
1595
|
+
raise ValueError("目录无法读取。") from exc
|
|
1596
|
+
|
|
1597
|
+
directories: list[DirectoryEntry] = []
|
|
1598
|
+
files: list[str] = []
|
|
1599
|
+
for child in children:
|
|
1600
|
+
if not show_hidden and child.name.startswith("."):
|
|
1601
|
+
continue
|
|
1602
|
+
try:
|
|
1603
|
+
if child.is_dir():
|
|
1604
|
+
directories.append(
|
|
1605
|
+
DirectoryEntry(name=child.name, path=str(child.resolve()))
|
|
1606
|
+
)
|
|
1607
|
+
else:
|
|
1608
|
+
files.append(child.name)
|
|
1609
|
+
except OSError:
|
|
1610
|
+
continue
|
|
1611
|
+
|
|
1612
|
+
directories.sort(key=lambda entry: entry.name.casefold())
|
|
1613
|
+
files.sort(key=str.casefold)
|
|
1614
|
+
return directories, files
|
|
1615
|
+
|
|
1616
|
+
def _replace_browser_state(
|
|
1617
|
+
self,
|
|
1618
|
+
chat_key: str,
|
|
1619
|
+
path: str,
|
|
1620
|
+
*,
|
|
1621
|
+
page: int,
|
|
1622
|
+
show_hidden: bool | None = None,
|
|
1623
|
+
previous: DirectoryBrowserState | None = None,
|
|
1624
|
+
) -> DirectoryBrowserState:
|
|
1625
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
1626
|
+
effective_show_hidden = (
|
|
1627
|
+
previous.show_hidden
|
|
1628
|
+
if show_hidden is None and previous is not None
|
|
1629
|
+
else bool(show_hidden)
|
|
1630
|
+
)
|
|
1631
|
+
entries, files = self._list_directory_entries(
|
|
1632
|
+
resolved,
|
|
1633
|
+
show_hidden=effective_show_hidden,
|
|
1634
|
+
)
|
|
1635
|
+
state = DirectoryBrowserState(
|
|
1636
|
+
chat_key=chat_key,
|
|
1637
|
+
current_path=resolved,
|
|
1638
|
+
page=self._clamp_browser_page(entries, page),
|
|
1639
|
+
token=previous.token if previous else self._make_browser_token(),
|
|
1640
|
+
version=(previous.version + 1) if previous else 1,
|
|
1641
|
+
entries=entries,
|
|
1642
|
+
show_hidden=effective_show_hidden,
|
|
1643
|
+
files=files,
|
|
1644
|
+
message_id=previous.message_id if previous else None,
|
|
1645
|
+
)
|
|
1646
|
+
self.directory_browsers[chat_key] = state
|
|
1647
|
+
return state
|
|
1648
|
+
|
|
1649
|
+
def open_directory_browser(self, chat_key: str) -> DirectoryBrowserState:
|
|
1650
|
+
self._ensure_not_running(chat_key)
|
|
1651
|
+
return self._replace_browser_state(
|
|
1652
|
+
chat_key,
|
|
1653
|
+
self.get_preferences(chat_key).workdir,
|
|
1654
|
+
page=0,
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
def get_browser(
|
|
1658
|
+
self,
|
|
1659
|
+
chat_key: str,
|
|
1660
|
+
token: str | None = None,
|
|
1661
|
+
version: int | None = None,
|
|
1662
|
+
) -> DirectoryBrowserState:
|
|
1663
|
+
state = self.directory_browsers.get(chat_key)
|
|
1664
|
+
if state is None:
|
|
1665
|
+
raise ValueError(BROWSER_STALE_MESSAGE)
|
|
1666
|
+
if token is not None and state.token != token:
|
|
1667
|
+
raise ValueError(BROWSER_STALE_MESSAGE)
|
|
1668
|
+
if version is not None and state.version != version:
|
|
1669
|
+
raise ValueError(BROWSER_STALE_MESSAGE)
|
|
1670
|
+
return state
|
|
1671
|
+
|
|
1672
|
+
def remember_browser_message(
|
|
1673
|
+
self, chat_key: str, token: str, message_id: int | None
|
|
1674
|
+
) -> None:
|
|
1675
|
+
if message_id is None:
|
|
1676
|
+
return
|
|
1677
|
+
browser = self.get_browser(chat_key, token=token)
|
|
1678
|
+
browser.message_id = message_id
|
|
1679
|
+
|
|
1680
|
+
def close_directory_browser(self, chat_key: str, token: str, version: int) -> None:
|
|
1681
|
+
self.get_browser(chat_key, token=token, version=version)
|
|
1682
|
+
self.directory_browsers.pop(chat_key, None)
|
|
1683
|
+
|
|
1684
|
+
def navigate_directory_browser(
|
|
1685
|
+
self,
|
|
1686
|
+
chat_key: str,
|
|
1687
|
+
token: str,
|
|
1688
|
+
version: int,
|
|
1689
|
+
action: str,
|
|
1690
|
+
index: int | None = None,
|
|
1691
|
+
) -> DirectoryBrowserState:
|
|
1692
|
+
browser = self.get_browser(chat_key, token=token, version=version)
|
|
1693
|
+
if action == "open":
|
|
1694
|
+
if index is None or not 0 <= index < len(browser.entries):
|
|
1695
|
+
raise ValueError("目录项不存在。")
|
|
1696
|
+
return self._replace_browser_state(
|
|
1697
|
+
chat_key,
|
|
1698
|
+
browser.entries[index].path,
|
|
1699
|
+
page=0,
|
|
1700
|
+
previous=browser,
|
|
1701
|
+
)
|
|
1702
|
+
if action == "up":
|
|
1703
|
+
return self._replace_browser_state(
|
|
1704
|
+
chat_key,
|
|
1705
|
+
str(Path(browser.current_path).parent),
|
|
1706
|
+
page=0,
|
|
1707
|
+
previous=browser,
|
|
1708
|
+
)
|
|
1709
|
+
if action == "root":
|
|
1710
|
+
root = Path(browser.current_path).anchor or "/"
|
|
1711
|
+
return self._replace_browser_state(
|
|
1712
|
+
chat_key,
|
|
1713
|
+
root,
|
|
1714
|
+
page=0,
|
|
1715
|
+
previous=browser,
|
|
1716
|
+
)
|
|
1717
|
+
if action == "home":
|
|
1718
|
+
return self._replace_browser_state(
|
|
1719
|
+
chat_key,
|
|
1720
|
+
str(Path.home()),
|
|
1721
|
+
page=0,
|
|
1722
|
+
previous=browser,
|
|
1723
|
+
)
|
|
1724
|
+
if action == "refresh":
|
|
1725
|
+
return self._replace_browser_state(
|
|
1726
|
+
chat_key,
|
|
1727
|
+
browser.current_path,
|
|
1728
|
+
page=browser.page,
|
|
1729
|
+
previous=browser,
|
|
1730
|
+
)
|
|
1731
|
+
if action == "toggle_hidden":
|
|
1732
|
+
return self._replace_browser_state(
|
|
1733
|
+
chat_key,
|
|
1734
|
+
browser.current_path,
|
|
1735
|
+
page=browser.page,
|
|
1736
|
+
show_hidden=not browser.show_hidden,
|
|
1737
|
+
previous=browser,
|
|
1738
|
+
)
|
|
1739
|
+
if action == "prev":
|
|
1740
|
+
return self._replace_browser_state(
|
|
1741
|
+
chat_key,
|
|
1742
|
+
browser.current_path,
|
|
1743
|
+
page=browser.page - 1,
|
|
1744
|
+
previous=browser,
|
|
1745
|
+
)
|
|
1746
|
+
if action == "next":
|
|
1747
|
+
return self._replace_browser_state(
|
|
1748
|
+
chat_key,
|
|
1749
|
+
browser.current_path,
|
|
1750
|
+
page=browser.page + 1,
|
|
1751
|
+
previous=browser,
|
|
1752
|
+
)
|
|
1753
|
+
raise ValueError("未知目录操作。")
|
|
1754
|
+
|
|
1755
|
+
async def apply_browser_directory(
|
|
1756
|
+
self, chat_key: str, token: str, version: int
|
|
1757
|
+
) -> str:
|
|
1758
|
+
browser = self.get_browser(chat_key, token=token, version=version)
|
|
1759
|
+
notice = await self.update_workdir(chat_key, browser.current_path)
|
|
1760
|
+
self._replace_browser_state(
|
|
1761
|
+
chat_key,
|
|
1762
|
+
browser.current_path,
|
|
1763
|
+
page=browser.page,
|
|
1764
|
+
previous=browser,
|
|
1765
|
+
)
|
|
1766
|
+
return notice
|
|
1767
|
+
|
|
1768
|
+
def render_directory_browser(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]:
|
|
1769
|
+
browser = self.get_browser(chat_key)
|
|
1770
|
+
preferences = self.get_preferences(chat_key)
|
|
1771
|
+
total_pages = self._browser_total_pages(browser.entries)
|
|
1772
|
+
start = browser.page * BROWSER_PAGE_SIZE
|
|
1773
|
+
end = start + BROWSER_PAGE_SIZE
|
|
1774
|
+
current_entries = browser.entries[start:end]
|
|
1775
|
+
|
|
1776
|
+
lines = [
|
|
1777
|
+
"目录浏览",
|
|
1778
|
+
f"浏览路径:{browser.current_path}",
|
|
1779
|
+
f"当前工作目录:{preferences.workdir}",
|
|
1780
|
+
f"子目录:{len(browser.entries)}",
|
|
1781
|
+
format_file_summary(browser.files),
|
|
1782
|
+
]
|
|
1783
|
+
if total_pages > 1:
|
|
1784
|
+
lines.append(f"第 {browser.page + 1}/{total_pages} 页")
|
|
1785
|
+
if not browser.entries:
|
|
1786
|
+
lines.append("当前目录没有子目录。")
|
|
1787
|
+
|
|
1788
|
+
keyboard: list[list[InlineKeyboardButton]] = []
|
|
1789
|
+
for offset, entry in enumerate(current_entries):
|
|
1790
|
+
keyboard.append(
|
|
1791
|
+
[
|
|
1792
|
+
InlineKeyboardButton(
|
|
1793
|
+
text=entry.name,
|
|
1794
|
+
callback_data=encode_browser_callback(
|
|
1795
|
+
browser.token,
|
|
1796
|
+
browser.version,
|
|
1797
|
+
"open",
|
|
1798
|
+
start + offset,
|
|
1799
|
+
),
|
|
1800
|
+
)
|
|
1801
|
+
]
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
if total_pages > 1:
|
|
1805
|
+
page_buttons: list[InlineKeyboardButton] = []
|
|
1806
|
+
if browser.page > 0:
|
|
1807
|
+
page_buttons.append(
|
|
1808
|
+
InlineKeyboardButton(
|
|
1809
|
+
text="上一页",
|
|
1810
|
+
callback_data=encode_browser_callback(
|
|
1811
|
+
browser.token,
|
|
1812
|
+
browser.version,
|
|
1813
|
+
"prev",
|
|
1814
|
+
),
|
|
1815
|
+
)
|
|
1816
|
+
)
|
|
1817
|
+
if browser.page + 1 < total_pages:
|
|
1818
|
+
page_buttons.append(
|
|
1819
|
+
InlineKeyboardButton(
|
|
1820
|
+
text="下一页",
|
|
1821
|
+
callback_data=encode_browser_callback(
|
|
1822
|
+
browser.token,
|
|
1823
|
+
browser.version,
|
|
1824
|
+
"next",
|
|
1825
|
+
),
|
|
1826
|
+
)
|
|
1827
|
+
)
|
|
1828
|
+
if page_buttons:
|
|
1829
|
+
keyboard.append(page_buttons)
|
|
1830
|
+
|
|
1831
|
+
keyboard.append(
|
|
1832
|
+
[
|
|
1833
|
+
InlineKeyboardButton(
|
|
1834
|
+
text="上一级",
|
|
1835
|
+
callback_data=encode_browser_callback(
|
|
1836
|
+
browser.token, browser.version, "up"
|
|
1837
|
+
),
|
|
1838
|
+
),
|
|
1839
|
+
InlineKeyboardButton(
|
|
1840
|
+
text="根目录 /",
|
|
1841
|
+
callback_data=encode_browser_callback(
|
|
1842
|
+
browser.token, browser.version, "root"
|
|
1843
|
+
),
|
|
1844
|
+
),
|
|
1845
|
+
InlineKeyboardButton(
|
|
1846
|
+
text="Home",
|
|
1847
|
+
callback_data=encode_browser_callback(
|
|
1848
|
+
browser.token, browser.version, "home"
|
|
1849
|
+
),
|
|
1850
|
+
),
|
|
1851
|
+
]
|
|
1852
|
+
)
|
|
1853
|
+
keyboard.append(
|
|
1854
|
+
[
|
|
1855
|
+
InlineKeyboardButton(
|
|
1856
|
+
text="隐藏 .开头项" if browser.show_hidden else "显示 .开头项",
|
|
1857
|
+
callback_data=encode_browser_callback(
|
|
1858
|
+
browser.token,
|
|
1859
|
+
browser.version,
|
|
1860
|
+
"toggle_hidden",
|
|
1861
|
+
),
|
|
1862
|
+
)
|
|
1863
|
+
]
|
|
1864
|
+
)
|
|
1865
|
+
keyboard.append(
|
|
1866
|
+
[
|
|
1867
|
+
InlineKeyboardButton(
|
|
1868
|
+
text="设为当前工作目录",
|
|
1869
|
+
callback_data=encode_browser_callback(
|
|
1870
|
+
browser.token,
|
|
1871
|
+
browser.version,
|
|
1872
|
+
"apply",
|
|
1873
|
+
),
|
|
1874
|
+
),
|
|
1875
|
+
InlineKeyboardButton(
|
|
1876
|
+
text="刷新",
|
|
1877
|
+
callback_data=encode_browser_callback(
|
|
1878
|
+
browser.token,
|
|
1879
|
+
browser.version,
|
|
1880
|
+
"refresh",
|
|
1881
|
+
),
|
|
1882
|
+
),
|
|
1883
|
+
]
|
|
1884
|
+
)
|
|
1885
|
+
keyboard.append(
|
|
1886
|
+
[
|
|
1887
|
+
InlineKeyboardButton(
|
|
1888
|
+
text="关闭",
|
|
1889
|
+
callback_data=encode_browser_callback(
|
|
1890
|
+
browser.token, browser.version, "close"
|
|
1891
|
+
),
|
|
1892
|
+
)
|
|
1893
|
+
]
|
|
1894
|
+
)
|
|
1895
|
+
return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
1896
|
+
|
|
1897
|
+
async def reset_chat(self, chat_key: str, *, keep_active: bool) -> ChatSession:
|
|
1898
|
+
session = self.get_session(chat_key)
|
|
1899
|
+
session.cancel_requested = True
|
|
1900
|
+
runner_task = session.runner_task
|
|
1901
|
+
await self._close_native_runner(session.native_runner)
|
|
1902
|
+
await terminate_process(session.process, self.settings.kill_timeout)
|
|
1903
|
+
current_task = asyncio.current_task()
|
|
1904
|
+
if runner_task is not None and runner_task is not current_task:
|
|
1905
|
+
try:
|
|
1906
|
+
await asyncio.wait_for(
|
|
1907
|
+
asyncio.shield(runner_task), timeout=self.settings.kill_timeout
|
|
1908
|
+
)
|
|
1909
|
+
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
|
|
1910
|
+
pass
|
|
1911
|
+
session.active = keep_active
|
|
1912
|
+
preferences = self.preference_overrides.get(chat_key)
|
|
1913
|
+
if preferences is not None:
|
|
1914
|
+
session.active_mode = preferences.default_mode
|
|
1915
|
+
elif session.active_mode not in {"resume", "exec"}:
|
|
1916
|
+
session.active_mode = "resume"
|
|
1917
|
+
session.native_thread_id = None
|
|
1918
|
+
session.exec_thread_id = None
|
|
1919
|
+
session.thread_id = None
|
|
1920
|
+
session.strict_resume = False
|
|
1921
|
+
session.running = False
|
|
1922
|
+
session.process = None
|
|
1923
|
+
session.native_runner = None
|
|
1924
|
+
session.runner_task = None
|
|
1925
|
+
session.progress_message_id = None
|
|
1926
|
+
session.stream_message_id = None
|
|
1927
|
+
session.last_agent_message = ""
|
|
1928
|
+
session.last_stream_text = ""
|
|
1929
|
+
session.last_stream_rendered_text = ""
|
|
1930
|
+
session.stream_message_truncated = False
|
|
1931
|
+
session.progress_lines.clear()
|
|
1932
|
+
session.diagnostics.clear()
|
|
1933
|
+
session.cancel_requested = False
|
|
1934
|
+
return session
|
|
1935
|
+
|
|
1936
|
+
async def update_model(self, chat_key: str, slug: str) -> str:
|
|
1937
|
+
self._ensure_not_running(chat_key)
|
|
1938
|
+
models = self.load_models()
|
|
1939
|
+
if slug not in models:
|
|
1940
|
+
raise ValueError("未找到指定模型。")
|
|
1941
|
+
|
|
1942
|
+
current = self.get_preferences(chat_key)
|
|
1943
|
+
model = models[slug]
|
|
1944
|
+
next_effort = current.reasoning_effort
|
|
1945
|
+
notice = ""
|
|
1946
|
+
if next_effort not in model.supported_reasoning_levels:
|
|
1947
|
+
downgraded = self._normalize_effort(model, "high")
|
|
1948
|
+
next_effort = downgraded
|
|
1949
|
+
notice = f"推理强度已自动降级为 {downgraded}。"
|
|
1950
|
+
|
|
1951
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
1952
|
+
model=slug,
|
|
1953
|
+
reasoning_effort=next_effort,
|
|
1954
|
+
permission_mode=current.permission_mode,
|
|
1955
|
+
workdir=current.workdir,
|
|
1956
|
+
default_mode=current.default_mode,
|
|
1957
|
+
)
|
|
1958
|
+
self._persist_preferences()
|
|
1959
|
+
self._clear_thread_only(chat_key)
|
|
1960
|
+
if notice:
|
|
1961
|
+
return f"{notice}\n当前设置:{self.describe_preferences(chat_key)}"
|
|
1962
|
+
return f"当前设置:{self.describe_preferences(chat_key)}"
|
|
1963
|
+
|
|
1964
|
+
async def update_reasoning_effort(self, chat_key: str, effort: str) -> str:
|
|
1965
|
+
self._ensure_not_running(chat_key)
|
|
1966
|
+
if effort not in SUPPORTED_EFFORT_COMMANDS:
|
|
1967
|
+
raise ValueError("仅支持 high 或 xhigh。")
|
|
1968
|
+
|
|
1969
|
+
current = self.get_preferences(chat_key)
|
|
1970
|
+
model = self.load_models().get(current.model)
|
|
1971
|
+
if model is None:
|
|
1972
|
+
raise ValueError("当前模型不在本地缓存中。")
|
|
1973
|
+
if effort not in model.supported_reasoning_levels:
|
|
1974
|
+
supported = ", ".join(model.supported_reasoning_levels)
|
|
1975
|
+
raise ValueError(f"当前模型仅支持:{supported}")
|
|
1976
|
+
|
|
1977
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
1978
|
+
model=current.model,
|
|
1979
|
+
reasoning_effort=effort,
|
|
1980
|
+
permission_mode=current.permission_mode,
|
|
1981
|
+
workdir=current.workdir,
|
|
1982
|
+
default_mode=current.default_mode,
|
|
1983
|
+
)
|
|
1984
|
+
self._persist_preferences()
|
|
1985
|
+
self._clear_thread_only(chat_key)
|
|
1986
|
+
return f"当前设置:{self.describe_preferences(chat_key)}"
|
|
1987
|
+
|
|
1988
|
+
async def update_permission_mode(self, chat_key: str, permission_mode: str) -> str:
|
|
1989
|
+
self._ensure_not_running(chat_key)
|
|
1990
|
+
if permission_mode not in SUPPORTED_PERMISSION_MODES:
|
|
1991
|
+
raise ValueError("仅支持 safe 或 danger。")
|
|
1992
|
+
|
|
1993
|
+
current = self.get_preferences(chat_key)
|
|
1994
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
1995
|
+
model=current.model,
|
|
1996
|
+
reasoning_effort=current.reasoning_effort,
|
|
1997
|
+
permission_mode=permission_mode,
|
|
1998
|
+
workdir=current.workdir,
|
|
1999
|
+
default_mode=current.default_mode,
|
|
2000
|
+
)
|
|
2001
|
+
self._persist_preferences()
|
|
2002
|
+
self._clear_thread_only(chat_key)
|
|
2003
|
+
return f"当前设置:{self.describe_preferences(chat_key)}"
|
|
2004
|
+
|
|
2005
|
+
async def update_workdir(self, chat_key: str, workdir: str) -> str:
|
|
2006
|
+
self._ensure_not_running(chat_key)
|
|
2007
|
+
resolved = self._resolve_directory_path(chat_key, workdir)
|
|
2008
|
+
current = self.get_preferences(chat_key)
|
|
2009
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
2010
|
+
model=current.model,
|
|
2011
|
+
reasoning_effort=current.reasoning_effort,
|
|
2012
|
+
permission_mode=current.permission_mode,
|
|
2013
|
+
workdir=resolved,
|
|
2014
|
+
default_mode=current.default_mode,
|
|
2015
|
+
)
|
|
2016
|
+
self._persist_preferences()
|
|
2017
|
+
self._clear_thread_only(chat_key)
|
|
2018
|
+
return self.describe_workdir(chat_key)
|
|
2019
|
+
|
|
2020
|
+
async def update_default_mode(self, chat_key: str, mode: str) -> str:
|
|
2021
|
+
self._ensure_not_running(chat_key)
|
|
2022
|
+
if mode not in {"resume", "exec"}:
|
|
2023
|
+
raise ValueError("仅支持 resume 或 exec。")
|
|
2024
|
+
|
|
2025
|
+
current = self.get_preferences(chat_key)
|
|
2026
|
+
self.preference_overrides[chat_key] = ChatPreferences(
|
|
2027
|
+
model=current.model,
|
|
2028
|
+
reasoning_effort=current.reasoning_effort,
|
|
2029
|
+
permission_mode=current.permission_mode,
|
|
2030
|
+
workdir=current.workdir,
|
|
2031
|
+
default_mode=mode,
|
|
2032
|
+
)
|
|
2033
|
+
self._persist_preferences()
|
|
2034
|
+
session = self.get_session(chat_key)
|
|
2035
|
+
session.active_mode = mode
|
|
2036
|
+
self._sync_legacy_thread_id(session)
|
|
2037
|
+
return f"当前默认模式:{mode}"
|
|
2038
|
+
|
|
2039
|
+
def get_supported_efforts(self, model_slug: str) -> list[str]:
|
|
2040
|
+
model = self.load_models().get(model_slug)
|
|
2041
|
+
if model is None:
|
|
2042
|
+
raise ValueError("未找到指定模型。")
|
|
2043
|
+
return model.supported_reasoning_levels
|
|
2044
|
+
|
|
2045
|
+
async def run_prompt(
|
|
2046
|
+
self,
|
|
2047
|
+
chat_key: str,
|
|
2048
|
+
prompt: str,
|
|
2049
|
+
*,
|
|
2050
|
+
mode_override: str | None = None,
|
|
2051
|
+
on_progress: ProgressCallback | None = None,
|
|
2052
|
+
on_stream_text: StreamTextCallback | None = None,
|
|
2053
|
+
) -> RunResult:
|
|
2054
|
+
session = self.activate_chat(chat_key)
|
|
2055
|
+
if session.running:
|
|
2056
|
+
raise RuntimeError("Codex is already running for this chat")
|
|
2057
|
+
if not self.which_resolver(self.settings.binary):
|
|
2058
|
+
raise FileNotFoundError(self.settings.binary)
|
|
2059
|
+
|
|
2060
|
+
clean_prompt = prompt.strip()
|
|
2061
|
+
if not clean_prompt:
|
|
2062
|
+
return RunResult(exit_code=0, notice="输入为空,未发送到 Codex。")
|
|
2063
|
+
|
|
2064
|
+
preferences = self.get_preferences(chat_key)
|
|
2065
|
+
mode = mode_override or session.active_mode or preferences.default_mode
|
|
2066
|
+
if mode == "resume" and self.native_client is not None:
|
|
2067
|
+
result = await self._run_native_prompt(
|
|
2068
|
+
session,
|
|
2069
|
+
clean_prompt,
|
|
2070
|
+
preferences=preferences,
|
|
2071
|
+
on_progress=on_progress,
|
|
2072
|
+
on_stream_text=on_stream_text,
|
|
2073
|
+
)
|
|
2074
|
+
return result
|
|
2075
|
+
|
|
2076
|
+
result = await self._run_exec_prompt(
|
|
2077
|
+
session,
|
|
2078
|
+
clean_prompt,
|
|
2079
|
+
previous_thread=self._current_exec_thread_id(session),
|
|
2080
|
+
preferences=preferences,
|
|
2081
|
+
on_progress=on_progress,
|
|
2082
|
+
on_stream_text=on_stream_text,
|
|
2083
|
+
)
|
|
2084
|
+
return result
|
|
2085
|
+
|
|
2086
|
+
async def _run_exec_prompt(
|
|
2087
|
+
self,
|
|
2088
|
+
session: ChatSession,
|
|
2089
|
+
prompt: str,
|
|
2090
|
+
*,
|
|
2091
|
+
previous_thread: str | None,
|
|
2092
|
+
preferences: ChatPreferences,
|
|
2093
|
+
on_progress: ProgressCallback | None,
|
|
2094
|
+
on_stream_text: StreamTextCallback | None,
|
|
2095
|
+
) -> RunResult:
|
|
2096
|
+
result = await self._run_exec_once(
|
|
2097
|
+
session,
|
|
2098
|
+
prompt,
|
|
2099
|
+
preferences=preferences,
|
|
2100
|
+
on_progress=on_progress,
|
|
2101
|
+
on_stream_text=on_stream_text,
|
|
2102
|
+
)
|
|
2103
|
+
if result.cancelled:
|
|
2104
|
+
return result
|
|
2105
|
+
|
|
2106
|
+
if (
|
|
2107
|
+
previous_thread
|
|
2108
|
+
and result.exit_code != 0
|
|
2109
|
+
and not result.final_text
|
|
2110
|
+
and not session.strict_resume
|
|
2111
|
+
):
|
|
2112
|
+
self._set_exec_thread_id(session, None)
|
|
2113
|
+
self._sync_legacy_thread_id(session)
|
|
2114
|
+
if on_progress is not None:
|
|
2115
|
+
await on_progress("原会话恢复失败,正在新开会话…")
|
|
2116
|
+
result = await self._run_exec_once(
|
|
2117
|
+
session,
|
|
2118
|
+
prompt,
|
|
2119
|
+
preferences=preferences,
|
|
2120
|
+
on_progress=on_progress,
|
|
2121
|
+
on_stream_text=on_stream_text,
|
|
2122
|
+
)
|
|
2123
|
+
result.notice = "原会话未成功恢复,已新开会话。"
|
|
2124
|
+
return result
|
|
2125
|
+
|
|
2126
|
+
if previous_thread and result.thread_id and result.thread_id != previous_thread:
|
|
2127
|
+
result.notice = "原会话未成功恢复,已自动切换到新会话。"
|
|
2128
|
+
return result
|
|
2129
|
+
|
|
2130
|
+
async def _run_exec_once(
|
|
2131
|
+
self,
|
|
2132
|
+
session: ChatSession,
|
|
2133
|
+
prompt: str,
|
|
2134
|
+
*,
|
|
2135
|
+
preferences: ChatPreferences,
|
|
2136
|
+
on_progress: ProgressCallback | None,
|
|
2137
|
+
on_stream_text: StreamTextCallback | None,
|
|
2138
|
+
) -> RunResult:
|
|
2139
|
+
session.running = True
|
|
2140
|
+
session.cancel_requested = False
|
|
2141
|
+
session.last_agent_message = ""
|
|
2142
|
+
session.last_stream_text = ""
|
|
2143
|
+
session.last_stream_rendered_text = ""
|
|
2144
|
+
session.stream_message_truncated = False
|
|
2145
|
+
session.progress_lines.clear()
|
|
2146
|
+
session.diagnostics.clear()
|
|
2147
|
+
|
|
2148
|
+
exec_thread_id = self._current_exec_thread_id(session)
|
|
2149
|
+
starting_new_thread = exec_thread_id is None
|
|
2150
|
+
argv = build_exec_argv(
|
|
2151
|
+
self.settings.binary,
|
|
2152
|
+
preferences.workdir,
|
|
2153
|
+
prompt,
|
|
2154
|
+
model=preferences.model,
|
|
2155
|
+
reasoning_effort=preferences.reasoning_effort,
|
|
2156
|
+
permission_mode=preferences.permission_mode,
|
|
2157
|
+
thread_id=exec_thread_id,
|
|
2158
|
+
)
|
|
2159
|
+
process = await self.launcher(
|
|
2160
|
+
*argv,
|
|
2161
|
+
stdout=asyncio.subprocess.PIPE,
|
|
2162
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
2163
|
+
cwd=preferences.workdir,
|
|
2164
|
+
limit=self.settings.stream_read_limit,
|
|
2165
|
+
)
|
|
2166
|
+
session.process = process
|
|
2167
|
+
|
|
2168
|
+
if on_progress is not None:
|
|
2169
|
+
await on_progress(
|
|
2170
|
+
render_progress_text(
|
|
2171
|
+
session,
|
|
2172
|
+
header=(
|
|
2173
|
+
format_preferences_summary(preferences)
|
|
2174
|
+
if starting_new_thread
|
|
2175
|
+
else None
|
|
2176
|
+
),
|
|
2177
|
+
)
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
stdout = getattr(process, "stdout", None)
|
|
2181
|
+
try:
|
|
2182
|
+
while stdout is not None:
|
|
2183
|
+
raw_line = await stdout.readline()
|
|
2184
|
+
if not raw_line:
|
|
2185
|
+
break
|
|
2186
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
2187
|
+
if not line:
|
|
2188
|
+
continue
|
|
2189
|
+
event = parse_event_line(line)
|
|
2190
|
+
if event is None:
|
|
2191
|
+
_append_diagnostic(session, line, self.settings.diagnostic_history)
|
|
2192
|
+
continue
|
|
2193
|
+
changed, stream_text = _apply_event(
|
|
2194
|
+
session,
|
|
2195
|
+
event,
|
|
2196
|
+
progress_history=self.settings.progress_history,
|
|
2197
|
+
)
|
|
2198
|
+
if event.get("type") == "thread.started":
|
|
2199
|
+
thread_id = event.get("thread_id")
|
|
2200
|
+
if isinstance(thread_id, str) and thread_id:
|
|
2201
|
+
self._set_exec_thread_id(session, thread_id)
|
|
2202
|
+
self._sync_legacy_thread_id(session)
|
|
2203
|
+
if changed and on_progress is not None:
|
|
2204
|
+
await on_progress(render_progress_text(session))
|
|
2205
|
+
if stream_text is not None and on_stream_text is not None:
|
|
2206
|
+
await on_stream_text(stream_text)
|
|
2207
|
+
|
|
2208
|
+
exit_code = await process.wait()
|
|
2209
|
+
cancelled = session.cancel_requested
|
|
2210
|
+
except Exception:
|
|
2211
|
+
await terminate_process(process, self.settings.kill_timeout)
|
|
2212
|
+
raise
|
|
2213
|
+
finally:
|
|
2214
|
+
session.running = False
|
|
2215
|
+
session.process = None
|
|
2216
|
+
session.cancel_requested = False
|
|
2217
|
+
|
|
2218
|
+
return RunResult(
|
|
2219
|
+
exit_code=exit_code,
|
|
2220
|
+
final_text=session.last_agent_message,
|
|
2221
|
+
thread_id=self._current_exec_thread_id(session),
|
|
2222
|
+
diagnostics=list(session.diagnostics),
|
|
2223
|
+
cancelled=cancelled,
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
async def _run_native_prompt(
|
|
2227
|
+
self,
|
|
2228
|
+
session: ChatSession,
|
|
2229
|
+
prompt: str,
|
|
2230
|
+
*,
|
|
2231
|
+
preferences: ChatPreferences,
|
|
2232
|
+
on_progress: ProgressCallback | None,
|
|
2233
|
+
on_stream_text: StreamTextCallback | None,
|
|
2234
|
+
) -> RunResult:
|
|
2235
|
+
if self.native_client is None:
|
|
2236
|
+
raise RuntimeError("Native Codex client is not configured.")
|
|
2237
|
+
|
|
2238
|
+
native_runner = self._spawn_native_client()
|
|
2239
|
+
if native_runner is None:
|
|
2240
|
+
raise RuntimeError("Native Codex client is not configured.")
|
|
2241
|
+
|
|
2242
|
+
session.running = True
|
|
2243
|
+
session.cancel_requested = False
|
|
2244
|
+
session.native_runner = native_runner
|
|
2245
|
+
session.runner_task = asyncio.current_task()
|
|
2246
|
+
session.last_agent_message = ""
|
|
2247
|
+
session.last_stream_text = ""
|
|
2248
|
+
session.last_stream_rendered_text = ""
|
|
2249
|
+
session.stream_message_truncated = False
|
|
2250
|
+
session.progress_lines.clear()
|
|
2251
|
+
session.diagnostics.clear()
|
|
2252
|
+
|
|
2253
|
+
starting_new_thread = session.native_thread_id is None
|
|
2254
|
+
if on_progress is not None:
|
|
2255
|
+
await on_progress(
|
|
2256
|
+
render_progress_text(
|
|
2257
|
+
session,
|
|
2258
|
+
header=(
|
|
2259
|
+
format_preferences_summary(preferences)
|
|
2260
|
+
if starting_new_thread
|
|
2261
|
+
else None
|
|
2262
|
+
),
|
|
2263
|
+
)
|
|
2264
|
+
)
|
|
2265
|
+
|
|
2266
|
+
async def forward_progress(line: str) -> None:
|
|
2267
|
+
_append_progress_line(session, line, self.settings.progress_history)
|
|
2268
|
+
if on_progress is not None:
|
|
2269
|
+
await on_progress(render_progress_text(session))
|
|
2270
|
+
|
|
2271
|
+
async def forward_stream_text(text: str) -> None:
|
|
2272
|
+
stripped = text.strip()
|
|
2273
|
+
if not stripped:
|
|
2274
|
+
return
|
|
2275
|
+
session.last_agent_message = stripped
|
|
2276
|
+
session.last_stream_text = stripped
|
|
2277
|
+
if on_stream_text is not None:
|
|
2278
|
+
await on_stream_text(stripped)
|
|
2279
|
+
|
|
2280
|
+
try:
|
|
2281
|
+
if session.native_thread_id is None:
|
|
2282
|
+
thread = await native_runner.start_thread(
|
|
2283
|
+
workdir=preferences.workdir,
|
|
2284
|
+
model=preferences.model,
|
|
2285
|
+
reasoning_effort=preferences.reasoning_effort,
|
|
2286
|
+
permission_mode=preferences.permission_mode,
|
|
2287
|
+
)
|
|
2288
|
+
else:
|
|
2289
|
+
thread = await native_runner.resume_thread(
|
|
2290
|
+
session.native_thread_id,
|
|
2291
|
+
workdir=preferences.workdir,
|
|
2292
|
+
model=preferences.model,
|
|
2293
|
+
reasoning_effort=preferences.reasoning_effort,
|
|
2294
|
+
permission_mode=preferences.permission_mode,
|
|
2295
|
+
)
|
|
2296
|
+
self._set_native_thread_id(session, thread.thread_id)
|
|
2297
|
+
native_result = await native_runner.run_turn(
|
|
2298
|
+
thread.thread_id,
|
|
2299
|
+
prompt,
|
|
2300
|
+
cwd=preferences.workdir,
|
|
2301
|
+
model=preferences.model,
|
|
2302
|
+
reasoning_effort=preferences.reasoning_effort,
|
|
2303
|
+
on_progress=forward_progress,
|
|
2304
|
+
on_stream_text=forward_stream_text,
|
|
2305
|
+
)
|
|
2306
|
+
final_thread_id = native_result.thread_id or thread.thread_id
|
|
2307
|
+
self._set_native_thread_id(session, final_thread_id)
|
|
2308
|
+
if native_result.final_text.strip():
|
|
2309
|
+
session.last_agent_message = native_result.final_text.strip()
|
|
2310
|
+
session.last_stream_text = native_result.final_text.strip()
|
|
2311
|
+
return RunResult(
|
|
2312
|
+
exit_code=native_result.exit_code,
|
|
2313
|
+
final_text=session.last_agent_message,
|
|
2314
|
+
thread_id=final_thread_id,
|
|
2315
|
+
diagnostics=list(native_result.diagnostics),
|
|
2316
|
+
cancelled=session.cancel_requested,
|
|
2317
|
+
)
|
|
2318
|
+
except Exception as exc:
|
|
2319
|
+
if session.cancel_requested:
|
|
2320
|
+
return RunResult(
|
|
2321
|
+
exit_code=1,
|
|
2322
|
+
thread_id=session.native_thread_id,
|
|
2323
|
+
diagnostics=list(session.diagnostics),
|
|
2324
|
+
cancelled=True,
|
|
2325
|
+
)
|
|
2326
|
+
_append_diagnostic(session, str(exc), self.settings.diagnostic_history)
|
|
2327
|
+
return RunResult(
|
|
2328
|
+
exit_code=1,
|
|
2329
|
+
thread_id=session.native_thread_id,
|
|
2330
|
+
diagnostics=list(session.diagnostics),
|
|
2331
|
+
)
|
|
2332
|
+
finally:
|
|
2333
|
+
await self._close_native_runner(session.native_runner)
|
|
2334
|
+
session.running = False
|
|
2335
|
+
session.process = None
|
|
2336
|
+
session.native_runner = None
|
|
2337
|
+
session.runner_task = None
|
|
2338
|
+
session.cancel_requested = False
|