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.
- illusion/__init__.py +24 -0
- illusion/__main__.py +15 -0
- illusion/_frontend/dist/index.mjs +39208 -0
- illusion/_frontend/package.json +27 -0
- illusion/_frontend/src/App.tsx +624 -0
- illusion/_frontend/src/components/CommandPicker.tsx +98 -0
- illusion/_frontend/src/components/Composer.tsx +55 -0
- illusion/_frontend/src/components/ComposerController.tsx +128 -0
- illusion/_frontend/src/components/ConversationView.tsx +750 -0
- illusion/_frontend/src/components/Footer.tsx +25 -0
- illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
- illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
- illusion/_frontend/src/components/ModalHost.tsx +425 -0
- illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
- illusion/_frontend/src/components/PromptInput.tsx +64 -0
- illusion/_frontend/src/components/SelectModal.tsx +78 -0
- illusion/_frontend/src/components/SidePanel.tsx +175 -0
- illusion/_frontend/src/components/Spinner.tsx +77 -0
- illusion/_frontend/src/components/StatusBar.tsx +142 -0
- illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
- illusion/_frontend/src/components/TodoPanel.tsx +126 -0
- illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
- illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
- illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
- illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
- illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
- illusion/_frontend/src/i18n.ts +78 -0
- illusion/_frontend/src/index.tsx +42 -0
- illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
- illusion/_frontend/src/theme/builtinThemes.ts +89 -0
- illusion/_frontend/src/types.ts +110 -0
- illusion/_frontend/src/utils/markdown.ts +33 -0
- illusion/_frontend/src/utils/thinking.ts +191 -0
- illusion/_frontend/tsconfig.json +13 -0
- illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
- illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
- illusion/_web_dist/index.html +16 -0
- illusion/api/__init__.py +36 -0
- illusion/api/client.py +568 -0
- illusion/api/codex_client.py +563 -0
- illusion/api/compat.py +138 -0
- illusion/api/effort.py +128 -0
- illusion/api/errors.py +57 -0
- illusion/api/openai_client.py +819 -0
- illusion/api/provider.py +148 -0
- illusion/api/registry.py +479 -0
- illusion/api/usage.py +45 -0
- illusion/auth/__init__.py +50 -0
- illusion/auth/copilot.py +419 -0
- illusion/auth/external.py +612 -0
- illusion/auth/flows.py +58 -0
- illusion/auth/manager.py +214 -0
- illusion/auth/storage.py +372 -0
- illusion/bridge/__init__.py +38 -0
- illusion/bridge/manager.py +190 -0
- illusion/bridge/session_runner.py +84 -0
- illusion/bridge/types.py +113 -0
- illusion/bridge/work_secret.py +131 -0
- illusion/cli.py +1228 -0
- illusion/commands/__init__.py +32 -0
- illusion/commands/registry.py +1934 -0
- illusion/config/__init__.py +39 -0
- illusion/config/i18n.py +522 -0
- illusion/config/paths.py +259 -0
- illusion/config/settings.py +564 -0
- illusion/coordinator/__init__.py +41 -0
- illusion/coordinator/agent_definitions.py +1093 -0
- illusion/coordinator/coordinator_mode.py +127 -0
- illusion/engine/__init__.py +95 -0
- illusion/engine/cost_tracker.py +55 -0
- illusion/engine/messages.py +369 -0
- illusion/engine/query.py +632 -0
- illusion/engine/query_engine.py +343 -0
- illusion/engine/stream_events.py +169 -0
- illusion/hooks/__init__.py +67 -0
- illusion/hooks/events.py +43 -0
- illusion/hooks/executor.py +397 -0
- illusion/hooks/hot_reload.py +74 -0
- illusion/hooks/loader.py +133 -0
- illusion/hooks/schemas.py +121 -0
- illusion/hooks/types.py +86 -0
- illusion/mcp/__init__.py +104 -0
- illusion/mcp/client.py +377 -0
- illusion/mcp/config.py +140 -0
- illusion/mcp/types.py +175 -0
- illusion/memory/__init__.py +36 -0
- illusion/memory/manager.py +94 -0
- illusion/memory/memdir.py +58 -0
- illusion/memory/paths.py +57 -0
- illusion/memory/scan.py +120 -0
- illusion/memory/search.py +83 -0
- illusion/memory/types.py +43 -0
- illusion/output_styles/__init__.py +15 -0
- illusion/output_styles/loader.py +64 -0
- illusion/permissions/__init__.py +39 -0
- illusion/permissions/checker.py +174 -0
- illusion/permissions/modes.py +38 -0
- illusion/platforms.py +148 -0
- illusion/plugins/__init__.py +71 -0
- illusion/plugins/bundled/__init__.py +0 -0
- illusion/plugins/installer.py +59 -0
- illusion/plugins/loader.py +301 -0
- illusion/plugins/schemas.py +51 -0
- illusion/plugins/types.py +56 -0
- illusion/prompts/__init__.py +29 -0
- illusion/prompts/claudemd.py +74 -0
- illusion/prompts/context.py +187 -0
- illusion/prompts/environment.py +189 -0
- illusion/prompts/system_prompt.py +155 -0
- illusion/py.typed +0 -0
- illusion/sandbox/__init__.py +29 -0
- illusion/sandbox/adapter.py +174 -0
- illusion/services/__init__.py +59 -0
- illusion/services/compact/__init__.py +1015 -0
- illusion/services/cron.py +338 -0
- illusion/services/cron_scheduler.py +715 -0
- illusion/services/file_history.py +258 -0
- illusion/services/lsp/__init__.py +455 -0
- illusion/services/session_storage.py +237 -0
- illusion/services/token_estimation.py +72 -0
- illusion/skills/__init__.py +60 -0
- illusion/skills/bundled/__init__.py +110 -0
- illusion/skills/bundled/content/batch.md +86 -0
- illusion/skills/bundled/content/coding-guidelines.md +70 -0
- illusion/skills/bundled/content/debug.md +38 -0
- illusion/skills/bundled/content/loop.md +82 -0
- illusion/skills/bundled/content/remember.md +105 -0
- illusion/skills/bundled/content/simplify.md +53 -0
- illusion/skills/bundled/content/skillify.md +113 -0
- illusion/skills/bundled/content/stuck.md +54 -0
- illusion/skills/bundled/content/update-config.md +329 -0
- illusion/skills/bundled/content/verify.md +74 -0
- illusion/skills/loader.py +219 -0
- illusion/skills/registry.py +40 -0
- illusion/skills/types.py +24 -0
- illusion/state/__init__.py +18 -0
- illusion/state/app_state.py +67 -0
- illusion/state/store.py +93 -0
- illusion/swarm/__init__.py +71 -0
- illusion/swarm/agent_executor.py +857 -0
- illusion/swarm/in_process.py +259 -0
- illusion/swarm/subprocess_backend.py +136 -0
- illusion/swarm/team_helpers.py +123 -0
- illusion/swarm/types.py +159 -0
- illusion/swarm/worktree.py +347 -0
- illusion/tasks/__init__.py +33 -0
- illusion/tasks/local_agent_task.py +42 -0
- illusion/tasks/local_shell_task.py +27 -0
- illusion/tasks/manager.py +377 -0
- illusion/tasks/stop_task.py +21 -0
- illusion/tasks/types.py +88 -0
- illusion/tools/__init__.py +126 -0
- illusion/tools/agent_tool.py +388 -0
- illusion/tools/ask_user_question_tool.py +186 -0
- illusion/tools/base.py +149 -0
- illusion/tools/bash_tool.py +413 -0
- illusion/tools/config_tool.py +90 -0
- illusion/tools/cron_tool.py +473 -0
- illusion/tools/enter_plan_mode_tool.py +147 -0
- illusion/tools/enter_worktree_tool.py +188 -0
- illusion/tools/exit_plan_mode_tool.py +69 -0
- illusion/tools/exit_worktree_tool.py +225 -0
- illusion/tools/file_edit_tool.py +283 -0
- illusion/tools/file_read_tool.py +294 -0
- illusion/tools/file_write_tool.py +184 -0
- illusion/tools/glob_tool.py +165 -0
- illusion/tools/grep_tool.py +190 -0
- illusion/tools/list_mcp_resources_tool.py +80 -0
- illusion/tools/lsp_tool.py +333 -0
- illusion/tools/mcp_auth_tool.py +100 -0
- illusion/tools/mcp_tool.py +75 -0
- illusion/tools/notebook_edit_tool.py +242 -0
- illusion/tools/powershell_tool.py +334 -0
- illusion/tools/read_mcp_resource_tool.py +63 -0
- illusion/tools/repl_tool.py +100 -0
- illusion/tools/send_message_tool.py +112 -0
- illusion/tools/shell_common.py +187 -0
- illusion/tools/skill_tool.py +86 -0
- illusion/tools/sleep_tool.py +62 -0
- illusion/tools/structured_output_tool.py +58 -0
- illusion/tools/task_create_tool.py +98 -0
- illusion/tools/task_get_tool.py +94 -0
- illusion/tools/task_list_tool.py +94 -0
- illusion/tools/task_output_tool.py +55 -0
- illusion/tools/task_stop_tool.py +52 -0
- illusion/tools/task_update_tool.py +224 -0
- illusion/tools/team_create_tool.py +236 -0
- illusion/tools/team_delete_tool.py +104 -0
- illusion/tools/todo_write_tool.py +198 -0
- illusion/tools/tool_search_tool.py +156 -0
- illusion/tools/web_fetch_tool.py +264 -0
- illusion/tools/web_search_tool.py +186 -0
- illusion/ui/__init__.py +23 -0
- illusion/ui/app.py +258 -0
- illusion/ui/backend_host.py +1180 -0
- illusion/ui/input.py +86 -0
- illusion/ui/output.py +363 -0
- illusion/ui/permission_dialog.py +47 -0
- illusion/ui/permission_store.py +99 -0
- illusion/ui/protocol.py +384 -0
- illusion/ui/react_launcher.py +280 -0
- illusion/ui/runtime.py +787 -0
- illusion/ui/textual_app.py +603 -0
- illusion/ui/web/__init__.py +10 -0
- illusion/ui/web/server.py +87 -0
- illusion/ui/web/ws_host.py +1197 -0
- illusion/utils/__init__.py +0 -0
- illusion/utils/ripgrep.py +299 -0
- illusion/utils/shell.py +248 -0
- illusion_code-0.1.0.dist-info/METADATA +1159 -0
- illusion_code-0.1.0.dist-info/RECORD +214 -0
- illusion_code-0.1.0.dist-info/WHEEL +4 -0
- illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|