illusion-code 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.
Files changed (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1180 @@
1
+ """
2
+ React 终端后端主机模块
3
+ ====================
4
+
5
+ 本模块实现 JSON-lines 协议的后端主机,用于与 React 终端前端通信。
6
+
7
+ 主要功能:
8
+ - 基于 stdin/stdout 的 JSON-lines 协议通信
9
+ - 命令处理(/provider, /resume, /permissions 等)
10
+ - 权限确认和工作流管理
11
+ - 会话状态快照
12
+ - 任务管理快照
13
+ - MCP 服务器状态管理
14
+
15
+ 类说明:
16
+ - BackendHostConfig: 后端主机配置数据类
17
+ - ReactBackendHost: 后端主机实现类
18
+
19
+ 使用示例:
20
+ >>> from illusion.ui.backend_host import run_backend_host
21
+ >>> await run_backend_host(model="claude-sonnet-4-20250514")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import json
29
+ import logging
30
+ import os
31
+ import re
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from uuid import uuid4
35
+
36
+ from illusion.api.client import SupportsStreamingMessages
37
+ from illusion.auth.manager import AuthManager
38
+ from illusion.bridge import get_bridge_manager
39
+ from illusion.engine.stream_events import (
40
+ AssistantTextDelta,
41
+ AssistantTurnComplete,
42
+ ErrorEvent,
43
+ StatusEvent,
44
+ StreamEvent,
45
+ ToolChainCompleted,
46
+ ToolChainStarted,
47
+ ToolExecutionCompleted,
48
+ ToolExecutionStarted,
49
+ )
50
+ from illusion.output_styles import load_output_styles
51
+ from illusion.tasks import get_task_manager
52
+ from illusion.ui.protocol import BackendEvent, FrontendRequest, TranscriptItem
53
+ from illusion.ui.permission_store import add_always_allowed_tool, load_always_allowed_tools
54
+ from illusion.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
55
+
56
+ # 配置模块级日志记录器
57
+ log = logging.getLogger(__name__)
58
+
59
+ # 协议前缀 - 用于标识 JSON-lines 协议
60
+ _PROTOCOL_PREFIX = "OHJSON:"
61
+
62
+
63
+ def _strip_tool_previews(text: str, tool_uses: list | None) -> str:
64
+ """从助手文本中移除工具预览行。
65
+
66
+ 使用实际工具名称精确匹配,不依赖前导空格数量。
67
+ """
68
+ if not tool_uses:
69
+ return text
70
+ names = [re.escape(tu.name) for tu in tool_uses]
71
+ pattern = re.compile(rf'^\s*(?:{"|".join(names)})\s*\(', re.IGNORECASE)
72
+ lines = text.split('\n')
73
+ filtered = [line for line in lines if not pattern.match(line)]
74
+ return '\n'.join(filtered) if filtered else text
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class BackendHostConfig:
79
+ """后端主机配置数据类。
80
+
81
+ Attributes:
82
+ model: 使用的模型名称
83
+ max_turns: 最大对话轮次
84
+ base_url: API 基础 URL
85
+ system_prompt: 系统提示词
86
+ api_key: API 密钥
87
+ api_format: API 格式(openai/anthropic)
88
+ api_client: 流式 API 客户端实例
89
+ restore_messages: 恢复的会话消息列表
90
+ enforce_max_turns: 是否强制限制最大轮次
91
+ effort: 推理强度级别(low/medium/high/xhigh/max)
92
+ """
93
+
94
+ model: str | None = None
95
+ max_turns: int | None = None
96
+ base_url: str | None = None
97
+ system_prompt: str | None = None
98
+ api_key: str | None = None
99
+ api_format: str | None = None
100
+ api_client: SupportsStreamingMessages | None = None
101
+ restore_messages: list[dict] | None = None
102
+ restore_session_id: str | None = None
103
+ enforce_max_turns: bool = True
104
+ effort: str | None = None
105
+
106
+
107
+ class ReactBackendHost:
108
+ """React 终端后端主机。
109
+
110
+ 通过 JSON-lines 协议与 React 前端通信,驱动 IllusionCode 运行时。
111
+ 处理所有前端请求并发送后端事件。
112
+
113
+ Attributes:
114
+ _config: 后端配置
115
+ _bundle: 运行时数据bundle
116
+ _write_lock: 异步写入锁
117
+ _request_queue: 请求队列
118
+ _permission_requests: 权限请求字典(request_id -> Future)
119
+ _question_requests: 用户问答请求字典
120
+ _always_allowed_tools: "总是允许"的工具集合
121
+ _busy: 当前是否正在处理请求
122
+ _running: 是否正在运行
123
+ _active_line_task: 当前活动的行处理任务
124
+ _last_tool_inputs: 每个工具名称的最后输入(用于富事件发射)
125
+ """
126
+
127
+ def __init__(self, config: BackendHostConfig) -> None:
128
+ self._config = config
129
+ self._bundle = None
130
+ self._write_lock = asyncio.Lock() # 异步写入锁
131
+ self._request_queue: asyncio.Queue[FrontendRequest] = asyncio.Queue()
132
+ self._permission_requests: dict[str, asyncio.Future[bool]] = {} # 权限请求
133
+ self._question_requests: dict[str, asyncio.Future[str]] = {} # 用户问答
134
+ self._always_allowed_tools: set[str] = set() # 总是允许的工具
135
+ self._busy = False # 忙碌状态
136
+ self._running = True # 运行状态
137
+ self._active_line_task: asyncio.Task[bool] | None = None # 当前任务
138
+ # 跟踪每个工具名称的最后输入,用于富事件发射
139
+ self._last_tool_inputs: dict[str, dict] = {}
140
+ # 跟踪已发送 tool_started 事件的工具调用ID,避免重复显示
141
+ self._emitted_tool_started_ids: set[str] = set()
142
+
143
+ async def run(self) -> int:
144
+ """运行后端主机主循环。"""
145
+ # 构建运行时环境
146
+ self._bundle = await build_runtime(
147
+ model=self._config.model,
148
+ max_turns=self._config.max_turns,
149
+ base_url=self._config.base_url,
150
+ system_prompt=self._config.system_prompt,
151
+ api_key=self._config.api_key,
152
+ api_format=self._config.api_format,
153
+ api_client=self._config.api_client,
154
+ restore_messages=self._config.restore_messages,
155
+ restore_session_id=self._config.restore_session_id,
156
+ permission_prompt=self._ask_permission,
157
+ ask_user_prompt=self._ask_question,
158
+ effort=self._config.effort,
159
+ )
160
+ await start_runtime(self._bundle)
161
+ # 加载总是允许的工具列表
162
+ self._always_allowed_tools = load_always_allowed_tools(self._bundle.cwd)
163
+ # 发送就绪事件
164
+ await self._emit(
165
+ BackendEvent.ready(
166
+ self._bundle.app_state.get(),
167
+ get_task_manager().list_tasks(),
168
+ [f"/{command.name}" for command in self._bundle.commands.list_commands()],
169
+ )
170
+ )
171
+ # 发送状态快照
172
+ await self._emit(self._status_snapshot())
173
+
174
+ # 创建请求读取任务
175
+ reader = asyncio.create_task(self._read_requests())
176
+
177
+ # 创建定期状态更新任务(每秒刷新一次,用于 agent 计数等实时状态)
178
+ async def _periodic_status_update():
179
+ while self._running:
180
+ await asyncio.sleep(1.0)
181
+ if self._running and self._bundle is not None:
182
+ await self._emit(self._status_snapshot())
183
+
184
+ status_updater = asyncio.create_task(_periodic_status_update())
185
+
186
+ try:
187
+ # 主循环:处理请求
188
+ while self._running:
189
+ request = await self._request_queue.get()
190
+ # 关闭请求
191
+ if request.type == "shutdown":
192
+ await self._emit(BackendEvent(type="shutdown"))
193
+ break
194
+ # 停止当前任务
195
+ if request.type == "stop":
196
+ await self._stop_active_line()
197
+ continue
198
+ # 权限响应
199
+ if request.type == "permission_response":
200
+ if request.request_id in self._permission_requests:
201
+ self._permission_requests[request.request_id].set_result(bool(request.allowed))
202
+ # 记住"总是允许"工具
203
+ if request.always_allow and request.tool_name:
204
+ self._always_allowed_tools.add(request.tool_name)
205
+ if self._bundle is not None:
206
+ self._always_allowed_tools = add_always_allowed_tool(
207
+ self._bundle.cwd,
208
+ request.tool_name,
209
+ )
210
+ await self._emit(BackendEvent(type="modal_request", modal=None))
211
+ continue
212
+ # 用户问答响应
213
+ if request.type == "question_response":
214
+ if request.request_id in self._question_requests:
215
+ answer = request.answer or ""
216
+ # 尝试解析 JSON 格式的多选答案
217
+ try:
218
+ parsed = json.loads(answer)
219
+ if isinstance(parsed, dict):
220
+ answer = parsed
221
+ except (json.JSONDecodeError, TypeError):
222
+ pass
223
+ self._question_requests[request.request_id].set_result(answer)
224
+ await self._emit(BackendEvent(type="modal_request", modal=None))
225
+ continue
226
+ # 列出会话
227
+ if request.type == "list_sessions":
228
+ await self._handle_list_sessions()
229
+ continue
230
+ # 选择命令
231
+ if request.type == "select_command":
232
+ await self._handle_select_command(request.command or "")
233
+ continue
234
+ # 应用选择命令
235
+ if request.type == "apply_select_command":
236
+ if self._busy:
237
+ await self._emit(BackendEvent(type="error", message="Session is busy"))
238
+ continue
239
+ self._busy = True
240
+ try:
241
+ self._active_line_task = asyncio.create_task(
242
+ self._apply_select_command(
243
+ request.command or "",
244
+ request.value or "",
245
+ )
246
+ )
247
+ should_continue = await self._active_line_task
248
+ except asyncio.CancelledError:
249
+ should_continue = True
250
+ finally:
251
+ self._active_line_task = None
252
+ self._busy = False
253
+ if not should_continue:
254
+ await self._emit(BackendEvent(type="shutdown"))
255
+ break
256
+ continue
257
+ # 未知请求类型
258
+ if request.type != "submit_line":
259
+ await self._emit(BackendEvent(type="error", message=f"Unknown request type: {request.type}"))
260
+ continue
261
+ # 忙碌中
262
+ if self._busy:
263
+ await self._emit(BackendEvent(type="error", message="Session is busy"))
264
+ continue
265
+ # 处理提交的行
266
+ line = (request.line or "").strip()
267
+ if not line:
268
+ continue
269
+ self._busy = True
270
+ try:
271
+ self._active_line_task = asyncio.create_task(self._process_line(line))
272
+ should_continue = await self._active_line_task
273
+ except asyncio.CancelledError:
274
+ should_continue = True
275
+ finally:
276
+ self._active_line_task = None
277
+ self._busy = False
278
+ if not should_continue:
279
+ await self._emit(BackendEvent(type="shutdown"))
280
+ break
281
+ finally:
282
+ # 清理资源
283
+ reader.cancel()
284
+ status_updater.cancel()
285
+ with contextlib.suppress(asyncio.CancelledError):
286
+ await reader
287
+ await status_updater
288
+ if self._bundle is not None:
289
+ await close_runtime(self._bundle)
290
+ return 0
291
+ async def _read_requests(self) -> None:
292
+ """从 stdin 读取请求。"""
293
+ while True:
294
+ raw = await asyncio.to_thread(sys.stdin.buffer.readline)
295
+ if not raw:
296
+ await self._request_queue.put(FrontendRequest(type="shutdown"))
297
+ return
298
+ payload = raw.decode("utf-8").strip()
299
+ if not payload:
300
+ continue
301
+ try:
302
+ request = FrontendRequest.model_validate_json(payload)
303
+ except Exception as exc: # 防御性协议处理
304
+ await self._emit(BackendEvent(type="error", message=f"Invalid request: {exc}"))
305
+ continue
306
+
307
+ # 立即解析模态对话框交互以避免死锁
308
+ # 主循环在 _process_line() 中等待用户输入
309
+ if request.type == "permission_response":
310
+ if request.request_id in self._permission_requests:
311
+ self._permission_requests[request.request_id].set_result(bool(request.allowed))
312
+ if request.always_allow and request.tool_name:
313
+ self._always_allowed_tools.add(request.tool_name)
314
+ if self._bundle is not None:
315
+ self._always_allowed_tools = add_always_allowed_tool(
316
+ self._bundle.cwd,
317
+ request.tool_name,
318
+ )
319
+ await self._emit(BackendEvent(type="modal_request", modal=None))
320
+ continue
321
+ if request.type == "stop":
322
+ await self._stop_active_line()
323
+ continue
324
+ if request.type == "question_response":
325
+ if request.request_id in self._question_requests:
326
+ self._question_requests[request.request_id].set_result(request.answer or "")
327
+ await self._emit(BackendEvent(type="modal_request", modal=None))
328
+ continue
329
+
330
+ await self._request_queue.put(request)
331
+
332
+ async def _process_line(self, line: str, *, transcript_line: str | None = None) -> bool:
333
+ """处理用户输入的行内容。"""
334
+ assert self._bundle is not None
335
+ # 清除上一轮的工具调用去重记录
336
+ self._emitted_tool_started_ids.clear()
337
+ # 更新会话阶段为思考中
338
+ await self._update_phase("thinking")
339
+ # 发送用户消息
340
+ await self._emit(
341
+ BackendEvent(type="transcript_item", item=TranscriptItem(role="user", text=transcript_line or line))
342
+ )
343
+
344
+ async def _print_system(message: str) -> None:
345
+ """打印系统消息。"""
346
+ await self._emit(
347
+ BackendEvent(type="transcript_item", item=TranscriptItem(role="system", text=message))
348
+ )
349
+
350
+ async def _render_event(event: StreamEvent) -> None:
351
+ """渲染流式事件。"""
352
+ # 助手文本增量
353
+ if isinstance(event, AssistantTextDelta):
354
+ reasoning = getattr(event, "reasoning", None)
355
+ await self._emit(BackendEvent(
356
+ type="assistant_delta",
357
+ message=event.text,
358
+ reasoning=reasoning if reasoning else None,
359
+ ))
360
+ return
361
+ # 助手回合完成
362
+ if isinstance(event, AssistantTurnComplete):
363
+ reasoning = event.message.thinking_text
364
+ cleaned = _strip_tool_previews(event.message.text.strip(), event.message.tool_uses)
365
+ await self._emit(
366
+ BackendEvent(
367
+ type="assistant_complete",
368
+ message=cleaned,
369
+ reasoning=reasoning if reasoning else None,
370
+ item=TranscriptItem(
371
+ role="assistant",
372
+ text=cleaned,
373
+ reasoning=reasoning if reasoning else None,
374
+ ),
375
+ )
376
+ )
377
+ self._brief_assistant_text = None
378
+ await self._emit(BackendEvent.tasks_snapshot(get_task_manager().list_tasks()))
379
+ return
380
+ # 工具链开始
381
+ if isinstance(event, ToolChainStarted):
382
+ await self._update_phase("tool_executing")
383
+ await self._emit(
384
+ BackendEvent(
385
+ type="tool_chain_started",
386
+ tool_count=event.tool_count,
387
+ )
388
+ )
389
+ return
390
+ # 工具链完成
391
+ if isinstance(event, ToolChainCompleted):
392
+ await self._update_phase("thinking")
393
+ await self._emit(
394
+ BackendEvent(
395
+ type="tool_chain_completed",
396
+ phase="thinking",
397
+ )
398
+ )
399
+ return
400
+ # 工具开始执行
401
+ if isinstance(event, ToolExecutionStarted):
402
+ tool_use_id = getattr(event, "tool_use_id", "") or ""
403
+ # 始终更新 _last_tool_inputs(即使已提前通知,也需要完整参数用于后续逻辑)
404
+ if event.tool_input:
405
+ self._last_tool_inputs[event.tool_name] = event.tool_input
406
+ # 通过 tool_use_id 去重:如果已发送过 tool_started 事件,则发送 tool_input_updated 更新参数
407
+ if tool_use_id and tool_use_id in self._emitted_tool_started_ids:
408
+ # 已提前通知过,发送参数更新事件让前端显示实际操作
409
+ if event.tool_input:
410
+ await self._emit(
411
+ BackendEvent(
412
+ type="tool_input_updated",
413
+ tool_name=event.tool_name,
414
+ tool_input=event.tool_input,
415
+ tool_use_id=tool_use_id,
416
+ )
417
+ )
418
+ return
419
+ if tool_use_id:
420
+ self._emitted_tool_started_ids.add(tool_use_id)
421
+ await self._emit(
422
+ BackendEvent(
423
+ type="tool_started",
424
+ tool_name=event.tool_name,
425
+ tool_input=event.tool_input,
426
+ item=TranscriptItem(
427
+ role="tool",
428
+ text=f"{event.tool_name} {json.dumps(event.tool_input, ensure_ascii=True)}" if event.tool_input else event.tool_name,
429
+ tool_name=event.tool_name,
430
+ tool_input=event.tool_input if event.tool_input else None,
431
+ tool_use_id=tool_use_id or None,
432
+ ),
433
+ )
434
+ )
435
+ return
436
+ # 工具执行完成
437
+ if isinstance(event, ToolExecutionCompleted):
438
+ tool_use_id = getattr(event, "tool_use_id", "") or ""
439
+ await self._emit(
440
+ BackendEvent(
441
+ type="tool_completed",
442
+ tool_name=event.tool_name,
443
+ output=event.output,
444
+ is_error=event.is_error,
445
+ tool_use_id=tool_use_id or None,
446
+ item=TranscriptItem(
447
+ role="tool_result",
448
+ text=event.output,
449
+ tool_name=event.tool_name,
450
+ is_error=event.is_error,
451
+ tool_use_id=tool_use_id or None,
452
+ ),
453
+ )
454
+ )
455
+ await self._emit(BackendEvent.tasks_snapshot(get_task_manager().list_tasks()))
456
+ await self._emit(self._status_snapshot())
457
+ # TodoWrite 工具执行时发送 todo_update 事件
458
+ if event.tool_name in ("TodoWrite", "todo_write"):
459
+ tool_input = self._last_tool_inputs.get(event.tool_name, {})
460
+ todos = tool_input.get("todos") or []
461
+ if isinstance(todos, list):
462
+ todo_items = []
463
+ for item in todos:
464
+ if isinstance(item, dict):
465
+ todo_items.append({
466
+ "content": item.get("content", ""),
467
+ "status": item.get("status", "pending"),
468
+ "activeForm": item.get("activeForm", item.get("content", "")),
469
+ })
470
+ if all(t.get("status") == "completed" for t in todo_items) and len(todo_items) >= 1:
471
+ todo_items = []
472
+ await self._emit(BackendEvent(type="todo_update", todo_items=todo_items))
473
+ # 计划相关工具完成时发送 plan_mode_change 事件
474
+ if event.tool_name in ("set_permission_mode", "plan_mode"):
475
+ assert self._bundle is not None
476
+ new_mode = self._bundle.app_state.get().permission_mode
477
+ await self._emit(BackendEvent(type="plan_mode_change", plan_mode=new_mode))
478
+ return
479
+ # 错误事件
480
+ if isinstance(event, ErrorEvent):
481
+ await self._emit(
482
+ BackendEvent(type="transcript_item", item=TranscriptItem(role="system", text=event.message))
483
+ )
484
+ return
485
+ # 状态事件
486
+ if isinstance(event, StatusEvent):
487
+ if event.bg_agent:
488
+ # 后台代理状态事件:发送到前端 shimmer 区域,不注入 UI
489
+ await self._emit(
490
+ BackendEvent(type="bg_agent_status", message=event.message)
491
+ )
492
+ else:
493
+ await self._emit(
494
+ BackendEvent(type="transcript_item", item=TranscriptItem(role="system", text=event.message))
495
+ )
496
+ return
497
+
498
+ async def _replay_transcript_item(item: dict) -> None:
499
+ """重播 transcript_item。"""
500
+ await self._emit(BackendEvent(type="transcript_item", item=TranscriptItem(**item)))
501
+
502
+ async def _clear_output() -> None:
503
+ """清空输出。"""
504
+ await self._emit(BackendEvent(type="clear_transcript"))
505
+
506
+ async def _command_result_emitter(message: str, result_type: str) -> None:
507
+ """发射指令结果事件。"""
508
+ await self._emit(BackendEvent(
509
+ type="command_result",
510
+ command_result_data={
511
+ "message": message,
512
+ "type": result_type,
513
+ },
514
+ ))
515
+
516
+ async def _replace_transcript_items(items: list[dict]) -> None:
517
+ """替换转录项列表(一次性清空并替换,避免 Ink Static 重复渲染)。"""
518
+ transcript_items = [TranscriptItem(**item) for item in items]
519
+ await self._emit(BackendEvent(type="replace_transcript", items=transcript_items))
520
+
521
+ should_continue = await handle_line(
522
+ self._bundle,
523
+ line,
524
+ print_system=_print_system,
525
+ render_event=_render_event,
526
+ clear_output=_clear_output,
527
+ replay_transcript_item=_replay_transcript_item,
528
+ command_result_emitter=_command_result_emitter,
529
+ replace_transcript_items=_replace_transcript_items,
530
+ )
531
+
532
+ # 更新会话阶段为空闲
533
+ await self._update_phase("idle")
534
+ await self._emit(self._status_snapshot())
535
+ await self._emit(BackendEvent.tasks_snapshot(get_task_manager().list_tasks()))
536
+ await self._emit(BackendEvent(type="line_complete"))
537
+ return should_continue
538
+
539
+ async def _apply_select_command(self, command_name: str, value: str) -> bool:
540
+ """应用选择的命令值。"""
541
+ command = command_name.strip().lstrip("/").lower()
542
+ selected = value.strip()
543
+ # 特殊路由:context → change window 时弹出子选择器
544
+ if command == "context" and selected == "__change_window__":
545
+ await self._handle_select_command("context-window")
546
+ return True
547
+ # 特殊路由:context-window → custom 时弹出输入框
548
+ if command == "context-window" and selected == "__custom__":
549
+ answer = await self._ask_question(
550
+ "请输入上下文窗口大小(tokens):"
551
+ if self._bundle and str(self._bundle.app_state.get().ui_language or "").lower().startswith("zh")
552
+ else "Enter context window size (tokens):"
553
+ )
554
+ await self._emit(BackendEvent(type="modal_request", modal=None))
555
+ answer = str(answer).strip()
556
+ if answer:
557
+ return await self._process_line(f"/context set {answer}", transcript_line="/context")
558
+ await self._emit(BackendEvent(type="line_complete"))
559
+ return True
560
+ line = self._build_select_command_line(command, selected)
561
+ if line is None:
562
+ await self._emit(BackendEvent(type="error", message=f"Unknown select command: {command_name}"))
563
+ await self._emit(BackendEvent(type="line_complete"))
564
+ return True
565
+ return await self._process_line(line, transcript_line=f"/{command}")
566
+
567
+ def _build_select_command_line(self, command: str, value: str) -> str | None:
568
+ """构建选择命令的实际命令字符串。"""
569
+ if command == "provider":
570
+ return f"/provider {value}"
571
+ if command == "resume":
572
+ return f"/resume {value}" if value else "/resume"
573
+ if command == "permissions":
574
+ return f"/permissions {value}"
575
+ if command == "language":
576
+ return f"/language {value}"
577
+ if command == "output-style":
578
+ return f"/output-style {value}"
579
+ if command == "effort":
580
+ return f"/effort {value}"
581
+ if command == "passes":
582
+ return f"/passes {value}"
583
+ if command == "turns":
584
+ return f"/turns {value}"
585
+ if command == "fast":
586
+ return f"/fast {value}"
587
+ if command == "language":
588
+ return f"/language {value}"
589
+ if command == "model":
590
+ return f"/model set {value}"
591
+ if command == "rewind":
592
+ # value is the message index to rewind to (before that message)
593
+ if self._bundle is None:
594
+ return None
595
+ try:
596
+ target_idx = int(value)
597
+ except ValueError:
598
+ return None
599
+ messages = self._bundle.engine.messages
600
+ turns = sum(
601
+ 1 for i, msg in enumerate(messages)
602
+ if i >= target_idx and msg.role == "user" and msg.text.strip() and not msg.text.strip().startswith("/")
603
+ )
604
+ return f"/rewind {turns}" if turns > 0 else None
605
+ if command == "delete":
606
+ if value == "__all__":
607
+ return "/delete all"
608
+ return f"/delete {value}"
609
+ if command == "rules":
610
+ return f"/rules {value}"
611
+ if command == "context":
612
+ if value == "__usage__":
613
+ return "/context __usage__"
614
+ return None
615
+ if command == "context-window":
616
+ return f"/context set {value}"
617
+ return None
618
+
619
+ def _status_snapshot(self) -> BackendEvent:
620
+ """生成状态快照事件。"""
621
+ assert self._bundle is not None
622
+ return BackendEvent.status_snapshot(
623
+ state=self._bundle.app_state.get(),
624
+ mcp_servers=self._bundle.mcp_manager.list_statuses(),
625
+ bridge_sessions=get_bridge_manager().list_sessions(),
626
+ )
627
+
628
+ async def _emit_todo_update_from_output(self, output: str) -> None:
629
+ """从工具输出中提取 markdown 复选框并发送 todo_update 事件。"""
630
+ # TodoWrite 工具通常会回显写入的内容
631
+ # 我们查找 markdown 复选框模式
632
+ lines = output.splitlines()
633
+ checklist_lines = [line for line in lines if line.strip().startswith("- [")]
634
+ if checklist_lines:
635
+ markdown = "\n".join(checklist_lines)
636
+ await self._emit(BackendEvent(type="todo_update", todo_markdown=markdown))
637
+
638
+ def _emit_swarm_status(self, teammates: list[dict], notifications: list[dict] | None = None) -> None:
639
+ """同步发送 swarm_status 事件(调度为协程)。"""
640
+ import asyncio
641
+ loop = asyncio.get_event_loop()
642
+ loop.create_task(
643
+ self._emit(BackendEvent(type="swarm_status", swarm_teammates=teammates, swarm_notifications=notifications))
644
+ )
645
+
646
+ async def _handle_list_sessions(self) -> None:
647
+ """处理列出会话请求。"""
648
+ from illusion.services.session_storage import list_session_snapshots
649
+ import time as _time
650
+
651
+ assert self._bundle is not None
652
+ locale = str(self._bundle.app_state.get().ui_language or self._bundle.current_settings().ui_language)
653
+ zh = locale.lower().startswith("zh")
654
+ sessions = list_session_snapshots(self._bundle.cwd, limit=10)
655
+ options = []
656
+ for s in sessions:
657
+ ts = _time.strftime("%m/%d %H:%M", _time.localtime(s["created_at"]))
658
+ summary = s.get("summary", "")[:50] or ("(无摘要)" if zh else "(no summary)")
659
+ options.append({
660
+ "value": s["session_id"],
661
+ "label": f"{ts} {s['message_count']}msg {summary}",
662
+ })
663
+ await self._emit(
664
+ BackendEvent(
665
+ type="select_request",
666
+ modal={"kind": "select", "title": "恢复会话" if zh else "Resume Session", "command": "resume"},
667
+ select_options=options,
668
+ )
669
+ )
670
+
671
+ async def _handle_select_command(self, command_name: str) -> None:
672
+ """处理选择命令请求。"""
673
+ assert self._bundle is not None
674
+ command = command_name.strip().lstrip("/").lower()
675
+ if command == "resume":
676
+ await self._handle_list_sessions()
677
+ return
678
+
679
+ settings = self._bundle.current_settings()
680
+ state = self._bundle.app_state.get()
681
+ locale = str(state.ui_language or settings.ui_language)
682
+ zh = locale.lower().startswith("zh")
683
+ current_model = settings.active_model_name
684
+
685
+ if command == "provider":
686
+ statuses = AuthManager(settings).get_env_statuses()
687
+ options = [
688
+ {
689
+ "value": env_key,
690
+ "label": f"{env_key} ({info['api_format']})",
691
+ "description": f"{info['api_format']} / {info['model']}" + (" [active]" if info["active"] else ""),
692
+ "active": info["active"],
693
+ }
694
+ for env_key, info in statuses.items()
695
+ ]
696
+ await self._emit(
697
+ BackendEvent(
698
+ type="select_request",
699
+ modal={"kind": "select", "title": "环境配置" if zh else "Env Config", "command": "provider"},
700
+ select_options=options,
701
+ )
702
+ )
703
+ return
704
+
705
+ if command == "permissions":
706
+ options = [
707
+ {
708
+ "value": "default",
709
+ "label": "默认" if zh else "Default",
710
+ "description": "写入/执行前询问" if zh else "Ask before write/execute operations",
711
+ "active": settings.permission.mode.value == "default",
712
+ },
713
+ {
714
+ "value": "full_auto",
715
+ "label": "自动" if zh else "Auto",
716
+ "description": "自动允许所有工具" if zh else "Allow all tools automatically",
717
+ "active": settings.permission.mode.value == "full_auto",
718
+ },
719
+ {
720
+ "value": "plan",
721
+ "label": "计划模式" if zh else "Plan Mode",
722
+ "description": "阻止所有写入操作" if zh else "Block all write operations",
723
+ "active": settings.permission.mode.value == "plan",
724
+ },
725
+ ]
726
+ await self._emit(
727
+ BackendEvent(
728
+ type="select_request",
729
+ modal={"kind": "select", "title": "权限模式" if zh else "Permission Mode", "command": "permissions"},
730
+ select_options=options,
731
+ )
732
+ )
733
+ return
734
+
735
+ if command == "output-style":
736
+ options = [
737
+ {
738
+ "value": style.name,
739
+ "label": style.name,
740
+ "description": style.source,
741
+ "active": style.name == settings.output_style,
742
+ }
743
+ for style in load_output_styles()
744
+ ]
745
+ await self._emit(
746
+ BackendEvent(
747
+ type="select_request",
748
+ modal={"kind": "select", "title": "输出风格" if zh else "Output Style", "command": "output-style"},
749
+ select_options=options,
750
+ )
751
+ )
752
+ return
753
+
754
+ if command == "effort":
755
+ options = [
756
+ {"value": "low", "label": "低" if zh else "Low", "description": "最快响应" if zh else "Fastest responses", "active": settings.effort == "low"},
757
+ {"value": "medium", "label": "中" if zh else "Medium", "description": "平衡推理" if zh else "Balanced reasoning", "active": settings.effort == "medium"},
758
+ {"value": "high", "label": "高" if zh else "High", "description": "最深推理" if zh else "Deepest reasoning", "active": settings.effort == "high"},
759
+ {"value": "xhigh", "label": "超高" if zh else "XHigh", "description": "超深推理" if zh else "Extra deep reasoning", "active": settings.effort == "xhigh"},
760
+ {"value": "max", "label": "最大" if zh else "Max", "description": "最大推理深度" if zh else "Maximum reasoning depth", "active": settings.effort == "max"},
761
+ ]
762
+ await self._emit(
763
+ BackendEvent(
764
+ type="select_request",
765
+ modal={"kind": "select", "title": "推理强度" if zh else "Reasoning Effort", "command": "effort"},
766
+ select_options=options,
767
+ )
768
+ )
769
+ return
770
+
771
+ if command == "passes":
772
+ current = int(state.passes or settings.passes)
773
+ options = [
774
+ {"value": str(value), "label": (f"{value} 轮" if zh else f"{value} pass{'es' if value != 1 else ''}"), "active": value == current}
775
+ for value in range(1, 9)
776
+ ]
777
+ await self._emit(
778
+ BackendEvent(
779
+ type="select_request",
780
+ modal={"kind": "select", "title": "推理轮数" if zh else "Reasoning Passes", "command": "passes"},
781
+ select_options=options,
782
+ )
783
+ )
784
+ return
785
+
786
+ if command == "turns":
787
+ current = self._bundle.engine.max_turns
788
+ values = {32, 64, 128, 200, 256, 512}
789
+ if isinstance(current, int):
790
+ values.add(current)
791
+ options = [{"value": "unlimited", "label": "无限" if zh else "Unlimited", "description": "不对本会话硬性停止" if zh else "Do not hard-stop this session", "active": current is None}]
792
+ options.extend(
793
+ {"value": str(value), "label": (f"{value} 轮" if zh else f"{value} turns"), "active": value == current}
794
+ for value in sorted(values)
795
+ )
796
+ await self._emit(
797
+ BackendEvent(
798
+ type="select_request",
799
+ modal={"kind": "select", "title": "最大轮数" if zh else "Max Turns", "command": "turns"},
800
+ select_options=options,
801
+ )
802
+ )
803
+ return
804
+
805
+ if command == "fast":
806
+ current = bool(state.fast_mode)
807
+ options = [
808
+ {"value": "on", "label": "开" if zh else "On", "description": "偏向更短更快的响应" if zh else "Prefer shorter, faster responses", "active": current},
809
+ {"value": "off", "label": "关" if zh else "Off", "description": "使用常规响应模式" if zh else "Use normal response mode", "active": not current},
810
+ ]
811
+ await self._emit(
812
+ BackendEvent(
813
+ type="select_request",
814
+ modal={"kind": "select", "title": "快速模式" if zh else "Fast Mode", "command": "fast"},
815
+ select_options=options,
816
+ )
817
+ )
818
+ return
819
+
820
+ if command == "language":
821
+ current = str(state.ui_language or "zh-CN")
822
+ options = [
823
+ {"value": "set zh-CN", "label": "简体中文", "description": "中文界面", "active": current == "zh-CN"},
824
+ {"value": "set en", "label": "English", "description": "English UI", "active": current == "en"},
825
+ ]
826
+ await self._emit(
827
+ BackendEvent(
828
+ type="select_request",
829
+ modal={"kind": "select", "title": "语言" if zh else "Language", "command": "language"},
830
+ select_options=options,
831
+ )
832
+ )
833
+ return
834
+
835
+ if command == "language":
836
+ current = str(state.ui_language or "zh-CN")
837
+ options = [
838
+ {"value": "set zh-CN", "label": "简体中文", "description": "中文界面", "active": current == "zh-CN"},
839
+ {"value": "set en", "label": "English", "description": "English UI", "active": current == "en"},
840
+ ]
841
+ await self._emit(
842
+ BackendEvent(
843
+ type="select_request",
844
+ modal={"kind": "select", "title": "语言" if zh else "Language", "command": "language"},
845
+ select_options=options,
846
+ )
847
+ )
848
+ return
849
+
850
+ if command == "model":
851
+ options = self._model_select_options(current_model, settings.provider)
852
+ await self._emit(
853
+ BackendEvent(
854
+ type="select_request",
855
+ modal={"kind": "select", "title": "模型" if zh else "Model", "command": "model"},
856
+ select_options=options,
857
+ )
858
+ )
859
+ return
860
+
861
+ if command == "rewind":
862
+ messages = self._bundle.engine.messages
863
+ user_msgs = [
864
+ (i, msg) for i, msg in enumerate(messages)
865
+ if msg.role == "user" and msg.text.strip() and not msg.text.strip().startswith("/")
866
+ ]
867
+ if not user_msgs:
868
+ await self._emit(BackendEvent(type="error", message=("没有可回退的消息。" if zh else "No messages to rewind to.")))
869
+ return
870
+ options = []
871
+ total = len(user_msgs)
872
+ for k, (idx, msg) in enumerate(reversed(user_msgs)):
873
+ text = msg.text.strip()
874
+ label = text[:80] + ("…" if len(text) > 80 else "")
875
+ options.append({
876
+ "value": str(idx),
877
+ "label": label,
878
+ "description": f"#{total - k}",
879
+ })
880
+ await self._emit(
881
+ BackendEvent(
882
+ type="select_request",
883
+ modal={"kind": "select", "title": "回退到" if zh else "Rewind to", "command": "rewind"},
884
+ select_options=options,
885
+ )
886
+ )
887
+ return
888
+
889
+ if command == "delete":
890
+ from illusion.services.session_storage import list_session_snapshots
891
+ import time as _time
892
+
893
+ sessions = list_session_snapshots(self._bundle.cwd, limit=10)
894
+ if not sessions:
895
+ await self._emit(BackendEvent(type="error", message=("没有已保存的会话。" if zh else "No saved sessions found.")))
896
+ return
897
+ options = []
898
+ for s in sessions:
899
+ ts = _time.strftime("%m/%d %H:%M", _time.localtime(s["created_at"]))
900
+ summary = s.get("summary", "")[:50] or ("(无摘要)" if zh else "(no summary)")
901
+ options.append({
902
+ "value": s["session_id"],
903
+ "label": f"{ts} {s['message_count']}msg {summary}",
904
+ })
905
+ options.append({
906
+ "value": "__all__",
907
+ "label": ("清除所有会话" if zh else "Delete all sessions"),
908
+ "description": ("删除全部已保存的会话快照" if zh else "Remove all saved session snapshots"),
909
+ })
910
+ await self._emit(
911
+ BackendEvent(
912
+ type="select_request",
913
+ modal={"kind": "select", "title": "删除会话" if zh else "Delete Session", "command": "delete"},
914
+ select_options=options,
915
+ )
916
+ )
917
+ return
918
+
919
+ if command == "rules":
920
+ from illusion.skills.loader import get_project_rules_dir
921
+
922
+ rules_dir = get_project_rules_dir(self._bundle.cwd)
923
+ rule_files = sorted(rules_dir.glob("*.md"))
924
+ if not rule_files:
925
+ await self._emit(BackendEvent(type="error", message=(f"没有找到规则文件:{rules_dir}" if zh else f"No rules found in {rules_dir}")))
926
+ return
927
+ options = []
928
+ for path in rule_files:
929
+ content = path.read_text(encoding="utf-8", errors="replace").strip()
930
+ first_line = content.split("\n", 1)[0][:60] if content else ("(空)" if zh else "(empty)")
931
+ options.append({
932
+ "value": path.stem,
933
+ "label": path.stem,
934
+ "description": first_line,
935
+ })
936
+ await self._emit(
937
+ BackendEvent(
938
+ type="select_request",
939
+ modal={"kind": "select", "title": "查看规则" if zh else "View Rules", "command": "rules"},
940
+ select_options=options,
941
+ )
942
+ )
943
+ return
944
+
945
+ if command == "context":
946
+ from illusion.services.compact import estimate_conversation_tokens
947
+
948
+ current_window = settings.context_window
949
+ estimated = estimate_conversation_tokens(self._bundle.engine.messages)
950
+ percentage = int(estimated * 100 / current_window) if current_window > 0 else 0
951
+ options = [
952
+ {
953
+ "value": "__change_window__",
954
+ "label": "修改上下文窗口大小" if zh else "Change context window size",
955
+ "description": f"当前: {current_window:,} tokens" if zh else f"Current: {current_window:,} tokens",
956
+ },
957
+ {
958
+ "value": "__usage__",
959
+ "label": "查看上下文使用情况" if zh else "View context usage",
960
+ "description": f"已用: ~{estimated:,} / {current_window:,} tokens ({percentage}%)" if zh else f"Used: ~{estimated:,} / {current_window:,} tokens ({percentage}%)",
961
+ },
962
+ ]
963
+ await self._emit(
964
+ BackendEvent(
965
+ type="select_request",
966
+ modal={"kind": "select", "title": "上下文管理" if zh else "Context Management", "command": "context"},
967
+ select_options=options,
968
+ )
969
+ )
970
+ return
971
+
972
+ if command == "context-window":
973
+ current = settings.context_window
974
+ preset_values = [128_000, 200_000, 512_000, 1_000_000]
975
+ if current not in preset_values:
976
+ preset_values.append(current)
977
+ preset_values.sort()
978
+ options = [
979
+ {
980
+ "value": str(v),
981
+ "label": f"{v:,} tokens",
982
+ "active": v == current,
983
+ }
984
+ for v in preset_values
985
+ ]
986
+ options.append({
987
+ "value": "__custom__",
988
+ "label": "其他(自定义输入)" if zh else "Other (custom)",
989
+ })
990
+ await self._emit(
991
+ BackendEvent(
992
+ type="select_request",
993
+ modal={"kind": "select", "title": "上下文窗口大小" if zh else "Context Window Size", "command": "context-window"},
994
+ select_options=options,
995
+ )
996
+ )
997
+ return
998
+
999
+ await self._emit(BackendEvent(type="error", message=(f"/{command} 暂无可选项" if zh else f"No selector available for /{command}")))
1000
+
1001
+ def _model_select_options(self, current_model: str, provider: str) -> list[dict[str, object]]:
1002
+ """从 settings.json 的 env_N 配置中提取所有实际可用的模型。"""
1003
+ assert self._bundle is not None
1004
+ settings = self._bundle.current_settings()
1005
+ envs = settings.list_envs()
1006
+
1007
+ seen: set[str] = set()
1008
+ options: list[dict[str, object]] = []
1009
+
1010
+ # 当前模型排第一位
1011
+ if current_model:
1012
+ seen.add(current_model)
1013
+ options.append({
1014
+ "value": current_model,
1015
+ "label": current_model,
1016
+ "description": "Current",
1017
+ "active": True,
1018
+ })
1019
+
1020
+ # 遍历所有 env,提取 model_N
1021
+ for env_key, env in envs.items():
1022
+ for model_key, model_name in env.list_models().items():
1023
+ ref = f"{env_key}.{model_key}"
1024
+ if ref in seen:
1025
+ continue
1026
+ seen.add(ref)
1027
+ is_current = ref == settings.model
1028
+ options.append({
1029
+ "value": ref,
1030
+ "label": model_name,
1031
+ "description": f"{env_key} ({env.api_format})",
1032
+ "active": is_current,
1033
+ })
1034
+
1035
+ return options
1036
+
1037
+ async def _ask_permission(self, tool_name: str, reason: str) -> bool:
1038
+ # 如果工具在"总是允许"列表中,则直接允许
1039
+ if tool_name in self._always_allowed_tools:
1040
+ return True
1041
+ request_id = uuid4().hex
1042
+ future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
1043
+ self._permission_requests[request_id] = future
1044
+ await self._emit(
1045
+ BackendEvent(
1046
+ type="modal_request",
1047
+ modal={
1048
+ "kind": "permission",
1049
+ "request_id": request_id,
1050
+ "tool_name": tool_name,
1051
+ "reason": reason,
1052
+ },
1053
+ )
1054
+ )
1055
+ try:
1056
+ return await asyncio.wait_for(future, timeout=300)
1057
+ except asyncio.TimeoutError:
1058
+ log.warning("Permission request %s timed out after 300s, denying", request_id)
1059
+ return False
1060
+ finally:
1061
+ self._permission_requests.pop(request_id, None)
1062
+
1063
+ async def _ask_question(self, question: str, questions: object = None) -> str | dict:
1064
+ request_id = uuid4().hex
1065
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
1066
+ self._question_requests[request_id] = future
1067
+ # 优先使用显式传入的结构化问题数据,回退到 _last_tool_inputs
1068
+ questions_data = questions
1069
+ if questions_data is None:
1070
+ tool_input = self._last_tool_inputs.get("ask_user_question", {})
1071
+ questions_data = tool_input.get("questions")
1072
+ # 如果是 pydantic 模型列表,转为 dict
1073
+ if questions_data is not None and not isinstance(questions_data, (dict, list)):
1074
+ questions_data = [
1075
+ q.model_dump() if hasattr(q, "model_dump") else q
1076
+ for q in questions_data
1077
+ ]
1078
+ modal_payload: dict = {
1079
+ "kind": "question",
1080
+ "request_id": request_id,
1081
+ "question": question,
1082
+ }
1083
+ if questions_data:
1084
+ modal_payload["questions"] = questions_data
1085
+ await self._emit(
1086
+ BackendEvent(
1087
+ type="modal_request",
1088
+ modal=modal_payload,
1089
+ )
1090
+ )
1091
+ try:
1092
+ return await future
1093
+ finally:
1094
+ self._question_requests.pop(request_id, None)
1095
+
1096
+ async def _stop_active_line(self) -> None:
1097
+ task = self._active_line_task
1098
+ if task is None or task.done():
1099
+ from illusion.config.i18n import t as _t
1100
+ await self._emit(BackendEvent(
1101
+ type="command_result",
1102
+ command_result_data={"message": _t("no_active_task"), "type": "info"},
1103
+ ))
1104
+ return
1105
+ task.cancel()
1106
+ with contextlib.suppress(asyncio.CancelledError):
1107
+ await task
1108
+ self._busy = False
1109
+ await self._update_phase("idle")
1110
+ await self._emit(BackendEvent(type="modal_request", modal=None))
1111
+ from illusion.config.i18n import t as _t
1112
+ stopped_message = _t("task_stopped")
1113
+ await self._emit(
1114
+ BackendEvent(
1115
+ type="transcript_item",
1116
+ item=TranscriptItem(role="system", text=stopped_message),
1117
+ )
1118
+ )
1119
+ await self._emit(BackendEvent(
1120
+ type="command_result",
1121
+ command_result_data={"message": stopped_message, "type": "info"},
1122
+ ))
1123
+ await self._emit(self._status_snapshot())
1124
+ await self._emit(BackendEvent.tasks_snapshot(get_task_manager().list_tasks()))
1125
+ await self._emit(BackendEvent(type="line_complete"))
1126
+
1127
+ async def _update_phase(self, phase: str) -> None:
1128
+ """更新会话阶段。"""
1129
+ assert self._bundle is not None
1130
+ self._bundle.app_state.set(phase=phase)
1131
+
1132
+ async def _emit(self, event: BackendEvent) -> None:
1133
+ async with self._write_lock:
1134
+ payload = _PROTOCOL_PREFIX + event.model_dump_json() + "\n"
1135
+ buffer = getattr(sys.stdout, "buffer", None)
1136
+ if buffer is not None:
1137
+ buffer.write(payload.encode("utf-8"))
1138
+ buffer.flush()
1139
+ return
1140
+ sys.stdout.write(payload)
1141
+ sys.stdout.flush()
1142
+
1143
+
1144
+ async def run_backend_host(
1145
+ *,
1146
+ model: str | None = None,
1147
+ max_turns: int | None = None,
1148
+ base_url: str | None = None,
1149
+ system_prompt: str | None = None,
1150
+ api_key: str | None = None,
1151
+ api_format: str | None = None,
1152
+ cwd: str | None = None,
1153
+ api_client: SupportsStreamingMessages | None = None,
1154
+ restore_messages: list[dict] | None = None,
1155
+ restore_session_id: str | None = None,
1156
+ enforce_max_turns: bool = True,
1157
+ effort: str | None = None,
1158
+ ) -> int:
1159
+ """Run the structured React backend host."""
1160
+ if cwd:
1161
+ os.chdir(cwd)
1162
+ host = ReactBackendHost(
1163
+ BackendHostConfig(
1164
+ model=model,
1165
+ max_turns=max_turns,
1166
+ base_url=base_url,
1167
+ system_prompt=system_prompt,
1168
+ api_key=api_key,
1169
+ api_format=api_format,
1170
+ api_client=api_client,
1171
+ restore_messages=restore_messages,
1172
+ restore_session_id=restore_session_id,
1173
+ enforce_max_turns=enforce_max_turns,
1174
+ effort=effort,
1175
+ )
1176
+ )
1177
+ return await host.run()
1178
+
1179
+
1180
+ __all__ = ["run_backend_host", "ReactBackendHost", "BackendHostConfig"]