299 lines
8.0 KiB
Markdown
299 lines
8.0 KiB
Markdown
|
|
# Phase 4:流式交互
|
|||
|
|
|
|||
|
|
日期:2026-04-04
|
|||
|
|
状态:待实施
|
|||
|
|
|
|||
|
|
依赖:Phase 3 完成
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 本阶段目的
|
|||
|
|
|
|||
|
|
实现 PTY 终端 + WebSocket 流式输出:
|
|||
|
|
- PTY 终端管理
|
|||
|
|
- WebSocket 端点
|
|||
|
|
- 流式输出集成
|
|||
|
|
- 交互输入
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. 详细任务
|
|||
|
|
|
|||
|
|
### 2.1 PTY Terminal Engine
|
|||
|
|
|
|||
|
|
**新文件**: `backend/app/agents/tools/terminal_engine.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import asyncio
|
|||
|
|
import os
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from typing import AsyncGenerator
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class PTYSession:
|
|||
|
|
session_id: str
|
|||
|
|
process: asyncio.subprocess.Process
|
|||
|
|
workspace_path: str
|
|||
|
|
|
|||
|
|
class PTYManager:
|
|||
|
|
def __init__(self):
|
|||
|
|
self._sessions: dict[str, PTYSession] = {}
|
|||
|
|
self._output_queues: dict[str, asyncio.Queue] = {}
|
|||
|
|
|
|||
|
|
async def spawn(
|
|||
|
|
self,
|
|||
|
|
cli: str,
|
|||
|
|
args: list[str],
|
|||
|
|
cwd: str,
|
|||
|
|
session_id: str | None = None
|
|||
|
|
) -> str:
|
|||
|
|
"""启动 PTY 会话"""
|
|||
|
|
if session_id is None:
|
|||
|
|
session_id = f"pty_{os.urandom(8).hex()}"
|
|||
|
|
|
|||
|
|
# 创建 PTY 进程
|
|||
|
|
process = await asyncio.create_subprocess_exec(
|
|||
|
|
*([cli] + args),
|
|||
|
|
stdout=asyncio.subprocess.PIPE,
|
|||
|
|
stderr=asyncio.subprocess.PIPE,
|
|||
|
|
cwd=cwd,
|
|||
|
|
env={**os.environ, "TERM": "xterm-256color"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
session = PTYSession(
|
|||
|
|
session_id=session_id,
|
|||
|
|
process=process,
|
|||
|
|
workspace_path=cwd,
|
|||
|
|
)
|
|||
|
|
self._sessions[session_id] = session
|
|||
|
|
self._output_queues[session_id] = asyncio.Queue()
|
|||
|
|
|
|||
|
|
# 启动输出读取协程
|
|||
|
|
asyncio.create_task(self._read_output(session_id))
|
|||
|
|
|
|||
|
|
return session_id
|
|||
|
|
|
|||
|
|
async def _read_output(self, session_id: str):
|
|||
|
|
"""读取 PTY 输出并放入队列"""
|
|||
|
|
session = self._sessions.get(session_id)
|
|||
|
|
if not session:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
queue = self._output_queues[session_id]
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
line = await session.process.stdout.readline()
|
|||
|
|
if not line:
|
|||
|
|
break
|
|||
|
|
await queue.put(line.decode())
|
|||
|
|
|
|||
|
|
# 同时推送给所有订阅者
|
|||
|
|
await self._broadcast(session_id, line.decode())
|
|||
|
|
|
|||
|
|
await queue.put(None) # 结束标记
|
|||
|
|
|
|||
|
|
async def write(self, session_id: str, data: str):
|
|||
|
|
"""写入 PTY(用户输入)"""
|
|||
|
|
session = self._sessions.get(session_id)
|
|||
|
|
if session and session.process.stdin:
|
|||
|
|
session.process.stdin.write(data)
|
|||
|
|
await session.process.stdin.drain()
|
|||
|
|
|
|||
|
|
async def read(self, session_id: str) -> AsyncGenerator[str, None]:
|
|||
|
|
"""读取 PTY 输出"""
|
|||
|
|
queue = self._output_queues.get(session_id)
|
|||
|
|
if not queue:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
line = await queue.get()
|
|||
|
|
if line is None:
|
|||
|
|
break
|
|||
|
|
yield line
|
|||
|
|
|
|||
|
|
async def resize(self, session_id: str, rows: int, cols: int):
|
|||
|
|
"""调整终端大小"""
|
|||
|
|
# TODO: 实现 resize
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
async def kill(self, session_id: str):
|
|||
|
|
"""终止 PTY 会话"""
|
|||
|
|
if session_id in self._sessions:
|
|||
|
|
session = self._sessions[session_id]
|
|||
|
|
session.process.terminate()
|
|||
|
|
await session.process.wait()
|
|||
|
|
del self._sessions[session_id]
|
|||
|
|
del self._output_queues[session_id]
|
|||
|
|
|
|||
|
|
async def _broadcast(self, session_id: str, data: str):
|
|||
|
|
"""广播输出到 WebSocket"""
|
|||
|
|
# 实际推送由 router 层处理
|
|||
|
|
pass
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 WebSocket 端点
|
|||
|
|
|
|||
|
|
**新文件**: `backend/app/routers/terminal.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|||
|
|
from typing import dict
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/ws/terminal", tags=["terminal"])
|
|||
|
|
|
|||
|
|
class ConnectionManager:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.active_connections: dict[str, WebSocket] = {}
|
|||
|
|
|
|||
|
|
async def connect(self, session_id: str, websocket: WebSocket):
|
|||
|
|
await websocket.accept()
|
|||
|
|
self.active_connections[session_id] = websocket
|
|||
|
|
|
|||
|
|
def disconnect(self, session_id: str):
|
|||
|
|
if session_id in self.active_connections:
|
|||
|
|
del self.active_connections[session_id]
|
|||
|
|
|
|||
|
|
async def send(self, session_id: str, data: str):
|
|||
|
|
if session_id in self.active_connections:
|
|||
|
|
await self.active_connections[session_id].send_text(data)
|
|||
|
|
|
|||
|
|
manager = ConnectionManager()
|
|||
|
|
|
|||
|
|
@router.websocket("/{session_id}")
|
|||
|
|
async def terminal_websocket(websocket: WebSocket, session_id: str):
|
|||
|
|
await manager.connect(session_id, websocket)
|
|||
|
|
|
|||
|
|
# 获取 PTY Manager 实例
|
|||
|
|
from app.agents.tools.terminal_engine import pty_manager
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 订阅该 session 的输出
|
|||
|
|
queue = pty_manager._output_queues.get(session_id)
|
|||
|
|
if queue:
|
|||
|
|
while True:
|
|||
|
|
data = await websocket.receive_text()
|
|||
|
|
# 接收用户输入
|
|||
|
|
await pty_manager.write(session_id, data + "\n")
|
|||
|
|
|
|||
|
|
except WebSocketDisconnect:
|
|||
|
|
manager.disconnect(session_id)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 流式输出集成
|
|||
|
|
|
|||
|
|
**新文件**: `backend/app/agents/tools/stream_output.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import json
|
|||
|
|
from typing import AsyncGenerator
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class StreamEvent:
|
|||
|
|
type: str # "output" | "error" | "status" | "complete"
|
|||
|
|
session_id: str
|
|||
|
|
data: str
|
|||
|
|
timestamp: str
|
|||
|
|
|
|||
|
|
class StreamOutput:
|
|||
|
|
def __init__(self, session_id: str, websocket_sender):
|
|||
|
|
self.session_id = session_id
|
|||
|
|
self.websocket_sender = websocket_sender
|
|||
|
|
|
|||
|
|
async def push(self, event_type: str, data: str):
|
|||
|
|
"""推送事件到 WebSocket"""
|
|||
|
|
event = StreamEvent(
|
|||
|
|
type=event_type,
|
|||
|
|
session_id=self.session_id,
|
|||
|
|
data=data,
|
|||
|
|
timestamp=datetime.now().isoformat(),
|
|||
|
|
)
|
|||
|
|
await self.websocket_sender(self.session_id, json.dumps(event.__dict__))
|
|||
|
|
|
|||
|
|
async def stream_execution(
|
|||
|
|
self,
|
|||
|
|
executor,
|
|||
|
|
prompt: str
|
|||
|
|
) -> AsyncGenerator[str, None]:
|
|||
|
|
"""包装执行器,实现流式输出"""
|
|||
|
|
async for line in executor.execute(prompt):
|
|||
|
|
await self.push("output", line)
|
|||
|
|
yield line
|
|||
|
|
|
|||
|
|
await self.push("complete", "")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 交互输入
|
|||
|
|
|
|||
|
|
**新文件**: `backend/app/agents/tools/interactive_input.py`
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class InteractiveInputHandler:
|
|||
|
|
def __init__(self, pty_manager: PTYManager):
|
|||
|
|
self.pty_manager = pty_manager
|
|||
|
|
self._pending_inputs: dict[str, asyncio.Event] = {}
|
|||
|
|
|
|||
|
|
async def wait_for_input(self, session_id: str, prompt: str) -> str:
|
|||
|
|
"""等待用户输入(如 "y" 确认)"""
|
|||
|
|
event = asyncio.Event()
|
|||
|
|
self._pending_inputs[session_id] = event
|
|||
|
|
|
|||
|
|
# 发送提示
|
|||
|
|
from app.routers.terminal import manager
|
|||
|
|
await manager.send(session_id, f"\n{prompt}\n")
|
|||
|
|
|
|||
|
|
# 等待输入完成
|
|||
|
|
await event.wait()
|
|||
|
|
del self._pending_inputs[session_id]
|
|||
|
|
|
|||
|
|
return self._input_cache.get(session_id, "")
|
|||
|
|
|
|||
|
|
async def send_input(self, session_id: str, data: str):
|
|||
|
|
"""用户发送输入"""
|
|||
|
|
self._input_cache[session_id] = data
|
|||
|
|
if session_id in self._pending_inputs:
|
|||
|
|
self._pending_inputs[session_id].set()
|
|||
|
|
|
|||
|
|
# 同时写入 PTY
|
|||
|
|
await self.pty_manager.write(session_id, data + "\n")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 核心文件清单
|
|||
|
|
|
|||
|
|
| 文件 | 操作 | 说明 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `terminal_engine.py` | 新增 | PTY 终端管理 |
|
|||
|
|
| `routers/terminal.py` | 新增 | WebSocket 端点 |
|
|||
|
|
| `stream_output.py` | 新增 | 流式输出封装 |
|
|||
|
|
| `interactive_input.py` | 新增 | 交互输入处理 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 验收标准
|
|||
|
|
|
|||
|
|
- [ ] PTY 会话可以启动、读写、终止
|
|||
|
|
- [ ] WebSocket 可以建立连接并收发消息
|
|||
|
|
- [ ] 执行输出实时推送到前端
|
|||
|
|
- [ ] 用户输入可以传递到 PTY
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 依赖关系
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Phase 3(Agent 集成)
|
|||
|
|
↓
|
|||
|
|
本阶段 → Phase 5(前端集成)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. 备注
|
|||
|
|
|
|||
|
|
PTY 实现参考了 golutra 的 `src-tauri/src/runtime/pty.rs`:
|
|||
|
|
- 使用 `portable-pty` 库
|
|||
|
|
- Windows 路径兼容处理
|
|||
|
|
- shim 机制用于信号处理
|