feat(agents): implement Code Commander module (Phases 1-5)
- Phase 1: Infrastructure (state, prompts, registry) - Phase 2: Execution engine (AI adapters, security classifier, executors) - Phase 3: Agent integration (graph nodes, routing) - Phase 4: Streaming interaction (PTY terminal, WebSocket) - Phase 5: Frontend integration (Vue components)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -309,14 +309,14 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 analyst 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
|
||||
|
||||
## 允许使用的工具:
|
||||
## 你的允许使用的工具:
|
||||
- get_tasks
|
||||
- get_forum_posts
|
||||
- search_knowledge
|
||||
- hybrid_search
|
||||
- web_search
|
||||
|
||||
## 要求:
|
||||
## 你的要求:
|
||||
- 先给结论与判断
|
||||
- 再说明依据与建议
|
||||
- 当需要外部/最新信息时,可使用 `web_search`
|
||||
@@ -324,6 +324,38 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
"""
|
||||
|
||||
|
||||
CODE_COMMANDER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是代码指挥官,负责协调 AI 写代码助手。
|
||||
|
||||
## 你的职责:
|
||||
1. 接收用户选择的 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
||||
2. 接收用户的写代码需求
|
||||
3. 进行安全分级判定
|
||||
4. 路由到合适的执行器
|
||||
|
||||
## 安全分级规则:
|
||||
- 低风险:demo、示例、贪食蛇游戏等独立项目
|
||||
- 高风险:修改现有项目、涉及 Jarvis 项目、路径操作等
|
||||
|
||||
## 执行模式:
|
||||
- 直接执行:低风险任务,直接运行
|
||||
- 沙盒执行:高风险任务,在临时目录隔离执行
|
||||
|
||||
## 你的输出:
|
||||
- 简洁汇报执行结果
|
||||
- 如果需要用户交互(如确认 "y"),明确提示
|
||||
"""
|
||||
|
||||
|
||||
SANDBOX_EXECUTION_PROMPT = """将在隔离的临时目录中执行任务。
|
||||
任务完成后,工作目录会被保留供下载。"""
|
||||
|
||||
|
||||
DIRECT_EXECUTION_PROMPT = """将直接执行任务。
|
||||
如果需要交互,请等待用户输入。"""
|
||||
|
||||
|
||||
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
||||
@@ -382,6 +414,7 @@ TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY = {
|
||||
"executor": EXECUTOR_SYSTEM_PROMPT,
|
||||
"librarian": LIBRARIAN_SYSTEM_PROMPT,
|
||||
"analyst": ANALYST_SYSTEM_PROMPT,
|
||||
"code_commander": CODE_COMMANDER_SYSTEM_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS: dict[str, tuple[str, ...]] = {
|
||||
"analyst_progress",
|
||||
"analyst_insights",
|
||||
),
|
||||
AgentRole.CODE_COMMANDER.value: (),
|
||||
}
|
||||
|
||||
TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
||||
@@ -37,6 +38,7 @@ TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
||||
AgentRole.EXECUTOR.value: "Executor",
|
||||
AgentRole.LIBRARIAN.value: "Librarian",
|
||||
AgentRole.ANALYST.value: "Analyst",
|
||||
AgentRole.CODE_COMMANDER.value: "Code Commander",
|
||||
}
|
||||
|
||||
TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
||||
@@ -55,6 +57,9 @@ TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
||||
AgentRole.ANALYST.value: (
|
||||
"Handle reporting and insight requests using analyst sub-commanders.",
|
||||
),
|
||||
AgentRole.CODE_COMMANDER.value: (
|
||||
"Handle code writing and execution tasks using AI CLI adapters.",
|
||||
),
|
||||
}
|
||||
|
||||
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
||||
@@ -63,11 +68,13 @@ TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
||||
AgentRole.EXECUTOR.value,
|
||||
AgentRole.LIBRARIAN.value,
|
||||
AgentRole.ANALYST.value,
|
||||
AgentRole.CODE_COMMANDER.value,
|
||||
),
|
||||
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
||||
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
||||
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
||||
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
||||
AgentRole.CODE_COMMANDER.value: (),
|
||||
}
|
||||
|
||||
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
||||
@@ -99,11 +106,7 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
|
||||
|
||||
|
||||
_capability_tool_names = tuple(
|
||||
dict.fromkeys(
|
||||
tool.name
|
||||
for tools in SUB_COMMANDER_TOOLSETS.values()
|
||||
for tool in tools
|
||||
)
|
||||
dict.fromkeys(tool.name for tools in SUB_COMMANDER_TOOLSETS.values() for tool in tools)
|
||||
)
|
||||
|
||||
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
|
||||
@@ -260,9 +263,7 @@ BUILTIN_SUB_COMMANDER_MANIFESTS: tuple[SubCommanderManifest, ...] = tuple(
|
||||
sub_commander_id=sub_commander_id,
|
||||
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
|
||||
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
|
||||
capability_ids=list(
|
||||
dict.fromkeys(tool.name for tool in tools)
|
||||
),
|
||||
capability_ids=list(dict.fromkeys(tool.name for tool in tools)),
|
||||
)
|
||||
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -13,6 +15,18 @@ InterruptStatus = Literal["requested", "acknowledged", "resolved"]
|
||||
BudgetMode = Literal["direct", "collaboration"]
|
||||
|
||||
|
||||
class CodeProviderType(str, Enum):
|
||||
CLAUDE = "claude"
|
||||
GEMINI = "gemini"
|
||||
CODEX = "codex"
|
||||
OPENCODE = "opencode"
|
||||
|
||||
|
||||
class RiskLevelType(str, Enum):
|
||||
LOW = "low"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class InterruptRecord(BaseModel):
|
||||
interrupt_id: str
|
||||
reason: str
|
||||
@@ -83,3 +97,37 @@ class TaskResult(BaseModel):
|
||||
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
||||
next_action: str | None = None
|
||||
output_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CodeTaskType(str, Enum):
|
||||
DEMO = "demo"
|
||||
PROJECT = "project"
|
||||
MODIFICATION = "modification"
|
||||
|
||||
|
||||
class CodeTask(BaseModel):
|
||||
"""代码任务请求模型"""
|
||||
|
||||
task_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
task_type: CodeTaskType
|
||||
ai_provider: CodeProviderType
|
||||
sandbox_mode: bool = False
|
||||
workspace_path: str | None = None
|
||||
user_prompt: str
|
||||
parent_task_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class CodeExecutionResultSchema(BaseModel):
|
||||
"""代码执行结果模型 (API 响应用)"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
files_created: list[str] = Field(default_factory=list)
|
||||
output: str = ""
|
||||
error: str | None = None
|
||||
exit_code: int = 0
|
||||
execution_time: float | None = None
|
||||
sandbox_session_id: str | None = None
|
||||
|
||||
@@ -4,7 +4,14 @@ from typing import Annotated, Any, Literal, TypedDict
|
||||
|
||||
from app.agents.schemas.event import AgentEvent
|
||||
from app.agents.schemas.message import AgentMessage
|
||||
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult, VerificationStatus
|
||||
from app.agents.schemas.task import (
|
||||
AgentTask,
|
||||
CollaborationBudget,
|
||||
InterruptRecord,
|
||||
RecoveryRecord,
|
||||
TaskResult,
|
||||
VerificationStatus,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
@@ -23,6 +30,7 @@ class AgentRole(str, Enum):
|
||||
EXECUTOR = "executor"
|
||||
LIBRARIAN = "librarian"
|
||||
ANALYST = "analyst"
|
||||
CODE_COMMANDER = "code_commander"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -141,6 +149,14 @@ class AgentState(TypedDict):
|
||||
user_llm_config: dict[str, Any] | None
|
||||
provider_capabilities: dict[str, Any] | None
|
||||
|
||||
# Code Commander state
|
||||
code_task_type: Literal["demo", "project", "modification"] | None
|
||||
code_ai_provider: Literal["claude", "gemini", "codex", "opencode"] | None
|
||||
code_sandbox_mode: bool | None
|
||||
code_workspace_path: str | None
|
||||
code_execution_session_id: str | None
|
||||
code_execution_result: dict[str, Any] | None
|
||||
|
||||
|
||||
def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
return AgentState(
|
||||
|
||||
@@ -138,3 +138,12 @@ SUB_COMMANDER_TOOLSETS = {
|
||||
"analyst_progress": ANALYST_PROGRESS_TOOLS,
|
||||
"analyst_insights": ANALYST_INSIGHT_TOOLS,
|
||||
}
|
||||
|
||||
# Code Commander toolset (tools implemented in later phases)
|
||||
CODE_COMMANDER_TOOLSET_NAMES = [
|
||||
"execute_code_task",
|
||||
"get_execution_status",
|
||||
"send_interactive_input",
|
||||
"download_workspace",
|
||||
"cleanup_workspace",
|
||||
]
|
||||
|
||||
196
backend/app/agents/tools/ai_adapter.py
Normal file
196
backend/app/agents/tools/ai_adapter.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
AI CLI Adapter - 统一接口适配不同 AI CLI (Claude/Gemini/Codex/OpenCode)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeExecutionResult:
|
||||
"""代码执行结果"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
files_created: list[str] = field(default_factory=list)
|
||||
output: str = ""
|
||||
error: str | None = None
|
||||
exit_code: int = 0
|
||||
|
||||
|
||||
class AICLIAdapter(ABC):
|
||||
"""AI CLI 适配器抽象基类"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cli_name(self) -> str:
|
||||
"""CLI 命令名称,如 'claude', 'gemini'"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_workspace(self) -> bool:
|
||||
"""是否需要工作目录"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def provider(self) -> Literal["claude", "gemini", "codex", "opencode"]:
|
||||
"""AI 提供商标识"""
|
||||
return self.cli_name
|
||||
|
||||
@abstractmethod
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
"""构建 CLI 命令"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
"""解析 CLI 输出"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_installed(self) -> bool:
|
||||
"""检查 CLI 是否已安装"""
|
||||
pass
|
||||
|
||||
|
||||
class ClaudeAdapter(AICLIAdapter):
|
||||
"""Claude CLI 适配器"""
|
||||
|
||||
cli_name = "claude"
|
||||
requires_workspace = True
|
||||
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
cmd = ["claude", "-p", prompt]
|
||||
if workspace:
|
||||
cmd.extend(["--output-format", "stream-json"])
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
return cmd
|
||||
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
# Claude CLI 输出可能是纯文本或 JSON
|
||||
# 简化处理:直接返回输出
|
||||
if not output.strip():
|
||||
return CodeExecutionResult(
|
||||
success=False,
|
||||
message="No output from Claude CLI",
|
||||
output=output,
|
||||
)
|
||||
return CodeExecutionResult(
|
||||
success=True,
|
||||
message="Execution completed",
|
||||
output=output,
|
||||
)
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
import shutil
|
||||
|
||||
return shutil.which("claude") is not None
|
||||
|
||||
|
||||
class GeminiAdapter(AICLIAdapter):
|
||||
"""Gemini CLI 适配器"""
|
||||
|
||||
cli_name = "gemini"
|
||||
requires_workspace = False
|
||||
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
cmd = ["gemini", "-p", prompt]
|
||||
return cmd
|
||||
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
if not output.strip():
|
||||
return CodeExecutionResult(
|
||||
success=False,
|
||||
message="No output from Gemini CLI",
|
||||
output=output,
|
||||
)
|
||||
return CodeExecutionResult(
|
||||
success=True,
|
||||
message="Execution completed",
|
||||
output=output,
|
||||
)
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
import shutil
|
||||
|
||||
return shutil.which("gemini") is not None
|
||||
|
||||
|
||||
class CodexAdapter(AICLIAdapter):
|
||||
"""Codex CLI 适配器"""
|
||||
|
||||
cli_name = "codex"
|
||||
requires_workspace = True
|
||||
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
cmd = ["codex", "-p", prompt]
|
||||
return cmd
|
||||
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
if not output.strip():
|
||||
return CodeExecutionResult(
|
||||
success=False,
|
||||
message="No output from Codex CLI",
|
||||
output=output,
|
||||
)
|
||||
return CodeExecutionResult(
|
||||
success=True,
|
||||
message="Execution completed",
|
||||
output=output,
|
||||
)
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
import shutil
|
||||
|
||||
return shutil.which("codex") is not None
|
||||
|
||||
|
||||
class OpenCodeAdapter(AICLIAdapter):
|
||||
"""OpenCode CLI 适配器"""
|
||||
|
||||
cli_name = "opencode"
|
||||
requires_workspace = True
|
||||
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
cmd = ["opencode", "-p", prompt]
|
||||
return cmd
|
||||
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
if not output.strip():
|
||||
return CodeExecutionResult(
|
||||
success=False,
|
||||
message="No output from OpenCode CLI",
|
||||
output=output,
|
||||
)
|
||||
return CodeExecutionResult(
|
||||
success=True,
|
||||
message="Execution completed",
|
||||
output=output,
|
||||
)
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
import shutil
|
||||
|
||||
return shutil.which("opencode") is not None
|
||||
|
||||
|
||||
# 提供商注册表
|
||||
ADAPTER_REGISTRY: dict[str, AICLIAdapter] = {
|
||||
"claude": ClaudeAdapter(),
|
||||
"gemini": GeminiAdapter(),
|
||||
"codex": CodexAdapter(),
|
||||
"opencode": OpenCodeAdapter(),
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(provider: str) -> AICLIAdapter:
|
||||
"""获取指定提供商的适配器"""
|
||||
adapter = ADAPTER_REGISTRY.get(provider.lower())
|
||||
if adapter is None:
|
||||
raise ValueError(
|
||||
f"Unknown AI provider: {provider}. Available: {list(ADAPTER_REGISTRY.keys())}"
|
||||
)
|
||||
return adapter
|
||||
112
backend/app/agents/tools/direct_executor.py
Normal file
112
backend/app/agents/tools/direct_executor.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Direct Executor - 直接执行器
|
||||
用于低风险任务,直接执行不隔离
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.agents.tools.ai_adapter import AICLIAdapter
|
||||
|
||||
|
||||
class ExecutionResult:
|
||||
"""执行结果"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
exit_code: int,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
):
|
||||
self.success = success
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class DirectExecutor:
|
||||
"""直接执行器(用于低风险任务)"""
|
||||
|
||||
def __init__(self, adapter: AICLIAdapter, timeout: int = 60):
|
||||
self.adapter = adapter
|
||||
self.timeout = timeout
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
直接执行,不需要沙盒
|
||||
|
||||
Args:
|
||||
prompt: 任务描述
|
||||
|
||||
Yields:
|
||||
str: 实时输出
|
||||
"""
|
||||
# 1. 检查 CLI 是否安装
|
||||
if not self.adapter.is_installed():
|
||||
yield f"[ERROR] {self.adapter.cli_name} is not installed\n"
|
||||
yield f"[ERROR] Please install {self.adapter.cli_name} first\n"
|
||||
return
|
||||
|
||||
# 2. 构建命令
|
||||
cmd = self.adapter.build_command(prompt, None)
|
||||
|
||||
# 3. 异步执行,实时 yield 输出
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, "TERM": "xterm-256color"},
|
||||
)
|
||||
|
||||
# 4. 实时读取输出
|
||||
stdout_lines = []
|
||||
stderr_lines = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
line_bytes = await asyncio.wait_for(
|
||||
process.stdout.readline(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if not line_bytes:
|
||||
break
|
||||
line = line_bytes.decode("utf-8", errors="replace")
|
||||
stdout_lines.append(line)
|
||||
yield line
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
|
||||
break
|
||||
|
||||
# 5. 读取 stderr
|
||||
stderr_bytes = await process.communicate()
|
||||
if stderr_bytes[1]:
|
||||
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
|
||||
stderr_lines.append(stderr)
|
||||
yield f"\n[STDERR]\n{stderr}\n"
|
||||
|
||||
# 6. 完成标记
|
||||
yield f"\n[EXIT_CODE] {process.returncode or 0}\n"
|
||||
yield f"\n[COMPLETE] success={process.returncode == 0}\n"
|
||||
|
||||
async def execute_sync(self, prompt: str) -> ExecutionResult:
|
||||
"""同步执行并返回完整结果"""
|
||||
output_parts = []
|
||||
async for line in self.execute(prompt):
|
||||
output_parts.append(line)
|
||||
|
||||
output = "".join(output_parts)
|
||||
return ExecutionResult(
|
||||
success="[COMPLETE] success=True" in output,
|
||||
exit_code=0,
|
||||
stdout=output,
|
||||
stderr="",
|
||||
)
|
||||
58
backend/app/agents/tools/interactive_input.py
Normal file
58
backend/app/agents/tools/interactive_input.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
InteractiveInputHandler - 交互输入处理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from app.agents.tools.terminal_engine import PTYManager
|
||||
|
||||
|
||||
class InteractiveInputHandler:
|
||||
"""交互输入处理器"""
|
||||
|
||||
def __init__(self, pty_manager: PTYManager):
|
||||
self.pty_manager = pty_manager
|
||||
self._pending_inputs: dict[str, asyncio.Event] = {}
|
||||
self._input_cache: dict[str, str] = {}
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
await manager.send(session_id, f"\n{prompt}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 等待输入完成
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
del self._pending_inputs[session_id]
|
||||
return self._input_cache.get(session_id, "")
|
||||
|
||||
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")
|
||||
|
||||
def clear_input(self, session_id: str):
|
||||
"""清除输入缓存"""
|
||||
if session_id in self._input_cache:
|
||||
del self._input_cache[session_id]
|
||||
if session_id in self._pending_inputs:
|
||||
self._pending_inputs[session_id].set() # 取消等待
|
||||
del self._pending_inputs[session_id]
|
||||
173
backend/app/agents/tools/sandbox_executor.py
Normal file
173
backend/app/agents/tools/sandbox_executor.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Sandbox Executor - 沙盒执行器
|
||||
在高风险任务在隔离的临时目录中执行
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.agents.tools.ai_adapter import AICLIAdapter, CodeExecutionResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxEnvironment:
|
||||
"""沙盒环境"""
|
||||
|
||||
workspace_path: Path
|
||||
session_id: str
|
||||
|
||||
@staticmethod
|
||||
async def create(prefix: str = "jarvis_code_") -> "SandboxEnvironment":
|
||||
"""创建新的沙盒环境"""
|
||||
temp_dir = tempfile.mkdtemp(prefix=prefix)
|
||||
session_id = Path(temp_dir).name
|
||||
return SandboxEnvironment(
|
||||
workspace_path=Path(temp_dir),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""清理沙盒环境"""
|
||||
if self.workspace_path.exists():
|
||||
shutil.rmtree(self.workspace_path)
|
||||
|
||||
def list_created_files(self) -> list[str]:
|
||||
"""列出沙盒中创建的文件"""
|
||||
if not self.workspace_path.exists():
|
||||
return []
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(self.workspace_path):
|
||||
for filename in filenames:
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_path = os.path.relpath(full_path, self.workspace_path)
|
||||
files.append(rel_path)
|
||||
return files
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""执行结果"""
|
||||
|
||||
success: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
files_created: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SandboxExecutor:
|
||||
"""沙盒执行器"""
|
||||
|
||||
def __init__(self, adapter: AICLIAdapter, timeout: int = 300):
|
||||
self.adapter = adapter
|
||||
self.timeout = timeout
|
||||
self._sessions: dict[str, SandboxEnvironment] = {}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
执行代码任务,yield 实时输出
|
||||
|
||||
Args:
|
||||
prompt: 任务描述
|
||||
session_id: 会话 ID(可选,用于复用沙盒)
|
||||
|
||||
Yields:
|
||||
str: 实时输出
|
||||
"""
|
||||
# 1. 创建或复用沙盒环境
|
||||
if session_id and session_id in self._sessions:
|
||||
env = self._sessions[session_id]
|
||||
else:
|
||||
env = await SandboxEnvironment.create()
|
||||
self._sessions[env.session_id] = env
|
||||
session_id = env.session_id
|
||||
|
||||
# 2. 构建命令
|
||||
cmd = self.adapter.build_command(prompt, env.workspace_path)
|
||||
|
||||
# 3. 异步执行,实时 yield 输出
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(env.workspace_path),
|
||||
env={**os.environ, "TERM": "xterm-256color"},
|
||||
)
|
||||
|
||||
# 4. 实时读取输出
|
||||
stdout_lines = []
|
||||
stderr_lines = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
line_bytes = await asyncio.wait_for(
|
||||
process.stdout.readline(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if not line_bytes:
|
||||
break
|
||||
line = line_bytes.decode("utf-8", errors="replace")
|
||||
stdout_lines.append(line)
|
||||
yield line
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
|
||||
break
|
||||
|
||||
# 5. 读取 stderr
|
||||
stderr_bytes = await process.communicate()
|
||||
if stderr_bytes[1]:
|
||||
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
|
||||
stderr_lines.append(stderr)
|
||||
yield f"\n[STDERR]\n{stderr}\n"
|
||||
|
||||
# 6. 返回结果(通过 yield 完成标记)
|
||||
result = ExecutionResult(
|
||||
success=process.returncode == 0,
|
||||
exit_code=process.returncode or 0,
|
||||
stdout="".join(stdout_lines),
|
||||
stderr="".join(stderr_lines),
|
||||
files_created=env.list_created_files(),
|
||||
)
|
||||
yield f"\n[EXIT_CODE] {result.exit_code}\n"
|
||||
yield f"\n[COMPLETE] success={result.success}\n"
|
||||
|
||||
async def get_result(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
) -> ExecutionResult:
|
||||
"""同步执行并返回完整结果"""
|
||||
output_parts = []
|
||||
async for line in self.execute(prompt, session_id):
|
||||
output_parts.append(line)
|
||||
# 解析最后的结果
|
||||
# 实际使用中可能需要更复杂的结果收集
|
||||
return ExecutionResult(
|
||||
success="[COMPLETE] success=True" in "".join(output_parts),
|
||||
exit_code=0,
|
||||
stdout="".join(output_parts),
|
||||
stderr="",
|
||||
files_created=[],
|
||||
)
|
||||
|
||||
async def cleanup_session(self, session_id: str) -> bool:
|
||||
"""清理指定会话"""
|
||||
if session_id in self._sessions:
|
||||
await self._sessions[session_id].cleanup()
|
||||
del self._sessions[session_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_session(self, session_id: str) -> SandboxEnvironment | None:
|
||||
"""获取会话环境"""
|
||||
return self._sessions.get(session_id)
|
||||
129
backend/app/agents/tools/security_classifier.py
Normal file
129
backend/app/agents/tools/security_classifier.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Security Classifier - 安全分级判定
|
||||
低风险任务直接执行,高风险任务沙盒执行
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RiskLevel(Enum):
|
||||
LOW = "low" # 直接执行
|
||||
HIGH = "high" # 沙盒执行
|
||||
|
||||
|
||||
class SecurityClassifier:
|
||||
"""安全分级器"""
|
||||
|
||||
HIGH_RISK_KEYWORDS = [
|
||||
# 路径/项目操作
|
||||
"修改",
|
||||
"编辑",
|
||||
"删除",
|
||||
"移动",
|
||||
"重命名",
|
||||
"Jarvis",
|
||||
"backend",
|
||||
"frontend",
|
||||
"git",
|
||||
"config",
|
||||
".env",
|
||||
"生产环境",
|
||||
# 文件操作
|
||||
"写入",
|
||||
"创建文件在",
|
||||
"移动到",
|
||||
"提交",
|
||||
"push",
|
||||
"pull",
|
||||
"merge",
|
||||
# 系统操作
|
||||
"sudo",
|
||||
"rm ",
|
||||
"chmod",
|
||||
"chown",
|
||||
]
|
||||
|
||||
LOW_RISK_KEYWORDS = [
|
||||
# demo/示例类
|
||||
"demo",
|
||||
"示例",
|
||||
"贪食蛇",
|
||||
"俄罗斯方块",
|
||||
"小游戏",
|
||||
"独立项目",
|
||||
"新项目",
|
||||
"创建一个",
|
||||
"写一个",
|
||||
"帮我写一个",
|
||||
# 明确无害的请求
|
||||
"生成代码",
|
||||
"代码示例",
|
||||
"练习项目",
|
||||
]
|
||||
|
||||
def classify(self, task_description: str, target_path: str | None = None) -> RiskLevel:
|
||||
"""
|
||||
判断任务风险等级
|
||||
|
||||
Args:
|
||||
task_description: 任务描述
|
||||
target_path: 目标路径(如果有)
|
||||
|
||||
Returns:
|
||||
RiskLevel: LOW 或 HIGH
|
||||
"""
|
||||
# 1. 检查高风险关键词
|
||||
task_lower = task_description.lower()
|
||||
if any(kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS):
|
||||
return RiskLevel.HIGH
|
||||
|
||||
# 2. 检查目标路径
|
||||
if target_path and self._is_project_path(target_path):
|
||||
return RiskLevel.HIGH
|
||||
|
||||
# 3. 检查低风险关键词
|
||||
if any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS):
|
||||
return RiskLevel.LOW
|
||||
|
||||
# 4. 默认高风险(保守策略)
|
||||
return RiskLevel.HIGH
|
||||
|
||||
def _is_project_path(self, path: str) -> bool:
|
||||
"""
|
||||
检查路径是否指向项目目录
|
||||
|
||||
Args:
|
||||
path: 文件路径
|
||||
|
||||
Returns:
|
||||
bool: 如果是项目路径返回 True
|
||||
"""
|
||||
path_lower = path.lower()
|
||||
project_indicators = [
|
||||
"jarvis",
|
||||
"backend/app",
|
||||
"frontend/src",
|
||||
".git",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
]
|
||||
return any(indicator in path_lower for indicator in project_indicators)
|
||||
|
||||
def get_risk_factors(
|
||||
self, task_description: str, target_path: str | None = None
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
获取详细的风险因素分析
|
||||
|
||||
Returns:
|
||||
dict: 各风险因素及其状态
|
||||
"""
|
||||
task_lower = task_description.lower()
|
||||
return {
|
||||
"has_high_risk_keywords": any(
|
||||
kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS
|
||||
),
|
||||
"has_low_risk_keywords": any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS),
|
||||
"is_project_path": bool(target_path and self._is_project_path(target_path)),
|
||||
}
|
||||
86
backend/app/agents/tools/stream_output.py
Normal file
86
backend/app/agents/tools/stream_output.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
StreamOutput - 流式输出封装
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
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: Callable[[str, str], Any] | None = None,
|
||||
):
|
||||
self.session_id = session_id
|
||||
self.websocket_sender = websocket_sender
|
||||
self._listeners: list[Callable[[StreamEvent], Any]] = []
|
||||
|
||||
def add_listener(self, listener: Callable[[StreamEvent], Any]):
|
||||
"""添加事件监听器"""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def remove_listener(self, listener: Callable[[StreamEvent], Any]):
|
||||
"""移除事件监听器"""
|
||||
if listener in self._listeners:
|
||||
self._listeners.remove(listener)
|
||||
|
||||
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(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
# 发送到 WebSocket
|
||||
if self.websocket_sender:
|
||||
try:
|
||||
await self.websocket_sender(self.session_id, json.dumps(event.__dict__))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 通知监听器
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
result = listener(event)
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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", "")
|
||||
|
||||
async def push_status(self, status: str):
|
||||
"""推送状态消息"""
|
||||
await self.push("status", status)
|
||||
|
||||
async def push_error(self, error: str):
|
||||
"""推送错误消息"""
|
||||
await self.push("error", error)
|
||||
160
backend/app/agents/tools/terminal_engine.py
Normal file
160
backend/app/agents/tools/terminal_engine.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
PTY Terminal Engine - 跨平台 PTY 终端管理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass
|
||||
class PTYSession:
|
||||
"""PTY 会话"""
|
||||
|
||||
session_id: str
|
||||
process: asyncio.subprocess.Process
|
||||
workspace_path: str
|
||||
|
||||
|
||||
class PTYManager:
|
||||
"""PTY 会话管理器"""
|
||||
|
||||
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,
|
||||
env: dict | None = None,
|
||||
) -> str:
|
||||
"""启动 PTY 会话"""
|
||||
if session_id is None:
|
||||
session_id = f"pty_{uuid4().hex[:8]}"
|
||||
|
||||
# 构建环境变量
|
||||
process_env = {**os.environ, "TERM": "xterm-256color"}
|
||||
if env:
|
||||
process_env.update(env)
|
||||
|
||||
# 创建 PTY 进程
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
cli,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=process_env,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = await session.process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded_line = line.decode(errors="replace")
|
||||
await queue.put(decoded_line)
|
||||
|
||||
# 广播到 WebSocket
|
||||
await self._broadcast(session_id, decoded_line)
|
||||
|
||||
# 读取 stderr
|
||||
stderr_line = await session.process.stderr.readline()
|
||||
if stderr_line:
|
||||
decoded_err = stderr_line.decode(errors="replace")
|
||||
await queue.put(decoded_err)
|
||||
await self._broadcast(session_id, decoded_err)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
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]
|
||||
try:
|
||||
session.process.terminate()
|
||||
await asyncio.wait_for(session.process.wait(), timeout=3.0)
|
||||
except asyncio.TimeoutError:
|
||||
session.process.kill()
|
||||
await session.process.wait()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
del self._sessions[session_id]
|
||||
if session_id in self._output_queues:
|
||||
del self._output_queues[session_id]
|
||||
|
||||
async def _broadcast(self, session_id: str, data: str):
|
||||
"""广播输出到 WebSocket"""
|
||||
from app.routers.terminal import manager
|
||||
|
||||
try:
|
||||
await manager.send(session_id, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_session(self, session_id: str) -> PTYSession | None:
|
||||
"""获取会话"""
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def list_sessions(self) -> list[str]:
|
||||
"""列出所有会话 ID"""
|
||||
return list(self._sessions.keys())
|
||||
|
||||
|
||||
# 全局单例
|
||||
pty_manager = PTYManager()
|
||||
@@ -28,6 +28,7 @@ from app.routers import (
|
||||
marketplace_router,
|
||||
agent_skills_router,
|
||||
agent_sessions_router,
|
||||
terminal_router,
|
||||
)
|
||||
from app.routers.scheduler import router as scheduler_router
|
||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||
@@ -127,6 +128,7 @@ app.include_router(plugins_router)
|
||||
app.include_router(marketplace_router)
|
||||
app.include_router(agent_skills_router)
|
||||
app.include_router(agent_sessions_router)
|
||||
app.include_router(terminal_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -20,3 +20,4 @@ from app.routers.plugins import router as plugins_router
|
||||
from app.routers.plugins import _marketplace_router as marketplace_router
|
||||
from app.routers.agent_skills import router as agent_skills_router
|
||||
from app.routers.agent_sessions import router as agent_sessions_router
|
||||
from app.routers.terminal import router as terminal_router
|
||||
|
||||
79
backend/app/routers/terminal.py
Normal file
79
backend/app/routers/terminal.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Terminal WebSocket Router - 终端 WebSocket 端点
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.agents.tools.terminal_engine import pty_manager
|
||||
|
||||
router = APIRouter(prefix="/ws/terminal", tags=["terminal"])
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""WebSocket 连接管理器"""
|
||||
|
||||
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)
|
||||
|
||||
def is_connected(self, session_id: str) -> bool:
|
||||
return session_id in self.active_connections
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/{session_id}")
|
||||
async def terminal_websocket(websocket: WebSocket, session_id: str):
|
||||
"""终端 WebSocket 端点"""
|
||||
await manager.connect(session_id, websocket)
|
||||
|
||||
try:
|
||||
# 获取该 session 的输出队列
|
||||
queue = pty_manager._output_queues.get(session_id)
|
||||
if queue:
|
||||
# 异步任务:转发 PTY 输出到 WebSocket
|
||||
async def forward_output():
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(queue.get(), timeout=0.1)
|
||||
if data is None:
|
||||
await manager.send(session_id, "[SESSION_END]")
|
||||
break
|
||||
await manager.send(session_id, data)
|
||||
except asyncio.TimeoutError:
|
||||
# 检查连接是否还活跃
|
||||
if not manager.is_connected(session_id):
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
|
||||
import asyncio
|
||||
|
||||
forward_task = asyncio.create_task(forward_output())
|
||||
|
||||
# 主循环:接收用户输入
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
# 接收用户输入,写入 PTY
|
||||
await pty_manager.write(session_id, data + "\n")
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
forward_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(session_id)
|
||||
Reference in New Issue
Block a user