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