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 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
|
你是 analyst 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
|
||||||
|
|
||||||
## 允许使用的工具:
|
## 你的允许使用的工具:
|
||||||
- get_tasks
|
- get_tasks
|
||||||
- get_forum_posts
|
- get_forum_posts
|
||||||
- search_knowledge
|
- search_knowledge
|
||||||
- hybrid_search
|
- hybrid_search
|
||||||
- web_search
|
- web_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}
|
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||||
|
|
||||||
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
||||||
@@ -382,6 +414,7 @@ TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY = {
|
|||||||
"executor": EXECUTOR_SYSTEM_PROMPT,
|
"executor": EXECUTOR_SYSTEM_PROMPT,
|
||||||
"librarian": LIBRARIAN_SYSTEM_PROMPT,
|
"librarian": LIBRARIAN_SYSTEM_PROMPT,
|
||||||
"analyst": ANALYST_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_progress",
|
||||||
"analyst_insights",
|
"analyst_insights",
|
||||||
),
|
),
|
||||||
|
AgentRole.CODE_COMMANDER.value: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
|
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.EXECUTOR.value: "Executor",
|
||||||
AgentRole.LIBRARIAN.value: "Librarian",
|
AgentRole.LIBRARIAN.value: "Librarian",
|
||||||
AgentRole.ANALYST.value: "Analyst",
|
AgentRole.ANALYST.value: "Analyst",
|
||||||
|
AgentRole.CODE_COMMANDER.value: "Code Commander",
|
||||||
}
|
}
|
||||||
|
|
||||||
TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
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: (
|
AgentRole.ANALYST.value: (
|
||||||
"Handle reporting and insight requests using analyst sub-commanders.",
|
"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, ...]] = {
|
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.EXECUTOR.value,
|
||||||
AgentRole.LIBRARIAN.value,
|
AgentRole.LIBRARIAN.value,
|
||||||
AgentRole.ANALYST.value,
|
AgentRole.ANALYST.value,
|
||||||
|
AgentRole.CODE_COMMANDER.value,
|
||||||
),
|
),
|
||||||
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
||||||
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
||||||
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
||||||
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
||||||
|
AgentRole.CODE_COMMANDER.value: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
||||||
@@ -99,11 +106,7 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
|
|||||||
|
|
||||||
|
|
||||||
_capability_tool_names = tuple(
|
_capability_tool_names = tuple(
|
||||||
dict.fromkeys(
|
dict.fromkeys(tool.name for tools in SUB_COMMANDER_TOOLSETS.values() for tool in tools)
|
||||||
tool.name
|
|
||||||
for tools in SUB_COMMANDER_TOOLSETS.values()
|
|
||||||
for tool in tools
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
|
_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,
|
sub_commander_id=sub_commander_id,
|
||||||
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
|
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
|
||||||
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
|
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
|
||||||
capability_ids=list(
|
capability_ids=list(dict.fromkeys(tool.name for tool in tools)),
|
||||||
dict.fromkeys(tool.name for tool in tools)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
|
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -13,6 +15,18 @@ InterruptStatus = Literal["requested", "acknowledged", "resolved"]
|
|||||||
BudgetMode = Literal["direct", "collaboration"]
|
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):
|
class InterruptRecord(BaseModel):
|
||||||
interrupt_id: str
|
interrupt_id: str
|
||||||
reason: str
|
reason: str
|
||||||
@@ -83,3 +97,37 @@ class TaskResult(BaseModel):
|
|||||||
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
||||||
next_action: str | None = None
|
next_action: str | None = None
|
||||||
output_data: dict[str, Any] | 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.event import AgentEvent
|
||||||
from app.agents.schemas.message import AgentMessage
|
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 langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||||
from langgraph.graph.message import add_messages
|
from langgraph.graph.message import add_messages
|
||||||
|
|
||||||
@@ -23,6 +30,7 @@ class AgentRole(str, Enum):
|
|||||||
EXECUTOR = "executor"
|
EXECUTOR = "executor"
|
||||||
LIBRARIAN = "librarian"
|
LIBRARIAN = "librarian"
|
||||||
ANALYST = "analyst"
|
ANALYST = "analyst"
|
||||||
|
CODE_COMMANDER = "code_commander"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -141,6 +149,14 @@ class AgentState(TypedDict):
|
|||||||
user_llm_config: dict[str, Any] | None
|
user_llm_config: dict[str, Any] | None
|
||||||
provider_capabilities: 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:
|
def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||||
return AgentState(
|
return AgentState(
|
||||||
|
|||||||
@@ -138,3 +138,12 @@ SUB_COMMANDER_TOOLSETS = {
|
|||||||
"analyst_progress": ANALYST_PROGRESS_TOOLS,
|
"analyst_progress": ANALYST_PROGRESS_TOOLS,
|
||||||
"analyst_insights": ANALYST_INSIGHT_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,
|
marketplace_router,
|
||||||
agent_skills_router,
|
agent_skills_router,
|
||||||
agent_sessions_router,
|
agent_sessions_router,
|
||||||
|
terminal_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
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(marketplace_router)
|
||||||
app.include_router(agent_skills_router)
|
app.include_router(agent_skills_router)
|
||||||
app.include_router(agent_sessions_router)
|
app.include_router(agent_sessions_router)
|
||||||
|
app.include_router(terminal_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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.plugins import _marketplace_router as marketplace_router
|
||||||
from app.routers.agent_skills import router as agent_skills_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.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)
|
||||||
@@ -22,13 +22,13 @@
|
|||||||
|
|
||||||
Day 1 目标:完成代码指挥官 Agent 的基础架子
|
Day 1 目标:完成代码指挥官 Agent 的基础架子
|
||||||
|
|
||||||
- [ ] 新增 `CODE_COMMANDER = "code_commander"` 到 `AgentRole` 枚举
|
- [x] 新增 `CODE_COMMANDER = "code_commander"` 到 `AgentRole` 枚举
|
||||||
- [ ] 新增 `CodeCommanderState` TypedDict(包含 task_type, ai_provider, sandbox_mode 等)
|
- [x] 新增 `CodeCommanderState` TypedDict(包含 task_type, ai_provider, sandbox_mode 等)
|
||||||
- [ ] 新增 `CODE_COMMANDER_SYSTEM_PROMPT` 系统提示
|
- [x] 新增 `CODE_COMMANDER_SYSTEM_PROMPT` 系统提示
|
||||||
- [ ] 新增 `SANDBOX_EXECUTION_PROMPT` 沙盒执行说明
|
- [x] 新增 `SANDBOX_EXECUTION_PROMPT` 沙盒执行说明
|
||||||
- [ ] 新增 `DIRECT_EXECUTION_PROMPT` 直接执行说明
|
- [x] 新增 `DIRECT_EXECUTION_PROMPT` 直接执行说明
|
||||||
- [ ] 在 `SUB_COMMANDER_TOOLSETS` 中注册 `CODE_COMMANDER_TOOLSET`
|
- [x] 在 `SUB_COMMANDER_TOOLSETS` 中注册 `CODE_COMMANDER_TOOLSET`
|
||||||
- [ ] 新增 `CodeCommanderManifest` 到 `AGENT_MANIFESTS`
|
- [x] 新增 `CodeCommanderManifest` 到 `AGENT_MANIFESTS`
|
||||||
- [ ] 补 Phase 1 单元测试
|
- [ ] 补 Phase 1 单元测试
|
||||||
|
|
||||||
**验收:确认 `AgentRole.CODE_COMMANDER` 存在且值正确**
|
**验收:确认 `AgentRole.CODE_COMMANDER` 存在且值正确**
|
||||||
@@ -39,17 +39,17 @@ Day 1 目标:完成代码指挥官 Agent 的基础架子
|
|||||||
|
|
||||||
Day 2 目标:实现适配不同 AI CLI 的统一接口
|
Day 2 目标:实现适配不同 AI CLI 的统一接口
|
||||||
|
|
||||||
- [ ] 新增 `AICLIAdapter` 抽象基类
|
- [x] 新增 `AICLIAdapter` 抽象基类
|
||||||
- `cli_name` 属性
|
- `cli_name` 属性
|
||||||
- `requires_workspace` 属性
|
- `requires_workspace` 属性
|
||||||
- `build_command()` 方法
|
- `build_command()` 方法
|
||||||
- `parse_output()` 方法
|
- `parse_output()` 方法
|
||||||
- `is_installed()` 方法
|
- `is_installed()` 方法
|
||||||
- [ ] 新增 `ClaudeAdapter` 实现
|
- [x] 新增 `ClaudeAdapter` 实现
|
||||||
- [ ] 新增 `GeminiAdapter` 实现
|
- [x] 新增 `GeminiAdapter` 实现
|
||||||
- [ ] 新增 `CodexAdapter` 实现
|
- [x] 新增 `CodexAdapter` 实现
|
||||||
- [ ] 新增 `OpenCodeAdapter` 实现
|
- [x] 新增 `OpenCodeAdapter` 实现
|
||||||
- [ ] 新增 `CodeExecutionResult` 数据类
|
- [x] 新增 `CodeExecutionResult` 数据类
|
||||||
- [ ] 补 Day 2 单元测试
|
- [ ] 补 Day 2 单元测试
|
||||||
|
|
||||||
**验收:`AICLIAdapter` 可以正确识别 4 种 CLI**
|
**验收:`AICLIAdapter` 可以正确识别 4 种 CLI**
|
||||||
@@ -60,13 +60,13 @@ Day 2 目标:实现适配不同 AI CLI 的统一接口
|
|||||||
|
|
||||||
Day 3 目标:实现安全分级和直接执行器
|
Day 3 目标:实现安全分级和直接执行器
|
||||||
|
|
||||||
- [ ] 新增 `RiskLevel` 枚举(LOW/HIGH)
|
- [x] 新增 `RiskLevel` 枚举(LOW/HIGH)
|
||||||
- [ ] 新增 `SecurityClassifier` 类
|
- [x] 新增 `SecurityClassifier` 类
|
||||||
- `HIGH_RISK_KEYWORDS` 列表
|
- `HIGH_RISK_KEYWORDS` 列表
|
||||||
- `LOW_RISK_KEYWORDS` 列表
|
- `LOW_RISK_KEYWORDS` 列表
|
||||||
- `classify()` 方法实现
|
- `classify()` 方法实现
|
||||||
- `_is_project_path()` 方法实现
|
- `_is_project_path()` 方法实现
|
||||||
- [ ] 新增 `DirectExecutor` 类
|
- [x] 新增 `DirectExecutor` 类
|
||||||
- `execute()` 方法(异步)
|
- `execute()` 方法(异步)
|
||||||
- 超时控制
|
- 超时控制
|
||||||
- `is_installed()` 检查
|
- `is_installed()` 检查
|
||||||
@@ -80,16 +80,16 @@ Day 3 目标:实现安全分级和直接执行器
|
|||||||
|
|
||||||
Day 4 目标:实现沙盒执行器
|
Day 4 目标:实现沙盒执行器
|
||||||
|
|
||||||
- [ ] 新增 `SandboxEnvironment` 类
|
- [x] 新增 `SandboxEnvironment` 类
|
||||||
- `create()` 静态方法(创建临时目录)
|
- `create()` 静态方法(创建临时目录)
|
||||||
- `cleanup()` 方法
|
- `cleanup()` 方法
|
||||||
- `workspace_path` 属性
|
- `workspace_path` 属性
|
||||||
- `session_id` 属性
|
- `session_id` 属性
|
||||||
- [ ] 新增 `SandboxExecutor` 类
|
- [x] 新增 `SandboxExecutor` 类
|
||||||
- `execute()` 方法(异步,yield 流式输出)
|
- `execute()` 方法(异步,yield 流式输出)
|
||||||
- `cleanup_session()` 方法
|
- `cleanup_session()` 方法
|
||||||
- `_list_created_files()` 方法
|
- `_list_created_files()` 方法
|
||||||
- [ ] 实现超时控制
|
- [x] 实现超时控制
|
||||||
- [ ] 补 Day 4 单元测试
|
- [ ] 补 Day 4 单元测试
|
||||||
|
|
||||||
**验收:`SandboxExecutor` 能创建、执行、清理沙盒**
|
**验收:`SandboxExecutor` 能创建、执行、清理沙盒**
|
||||||
@@ -115,15 +115,15 @@ Day 5 目标:确保执行引擎各组件协同工作
|
|||||||
|
|
||||||
Day 6 目标:将代码指挥官接入 LangGraph
|
Day 6 目标:将代码指挥官接入 LangGraph
|
||||||
|
|
||||||
- [ ] 新增 `code_commander_node` 函数
|
- [x] 新增 `code_commander_node` 函数
|
||||||
- 获取用户需求和 AI 提供商
|
- 获取用户需求和 AI 提供商
|
||||||
- 调用 `SecurityClassifier`
|
- 调用 `SecurityClassifier`
|
||||||
- 根据风险等级选择执行器
|
- 根据风险等级选择执行器
|
||||||
- 返回执行结果
|
- 返回执行结果
|
||||||
- [ ] 在 `NODES` 字典中注册 `code_commander`
|
- [x] 在 `NODES` 字典中注册 `code_commander`
|
||||||
- [ ] 新增 `_should_route_to_code_commander()` 路由函数
|
- [x] 新增 `_should_route_to_code_commander()` 路由函数
|
||||||
- [ ] 在 `graph.py` 中添加条件边
|
- [x] 在 `graph.py` 中添加条件边
|
||||||
- [ ] 新增 `CodeTask`, `CodeExecutionResult` 模型到 `schemas/task.py`
|
- [x] 新增 `CodeTask`, `CodeExecutionResult` 模型到 `schemas/task.py`
|
||||||
- [ ] 补 Day 6 单元测试
|
- [ ] 补 Day 6 单元测试
|
||||||
|
|
||||||
**验收:高风险任务路由到沙盒,低风险路由到直接执行**
|
**验收:高风险任务路由到沙盒,低风险路由到直接执行**
|
||||||
@@ -134,15 +134,15 @@ Day 6 目标:将代码指挥官接入 LangGraph
|
|||||||
|
|
||||||
Day 7 目标:实现 PTY 终端管理
|
Day 7 目标:实现 PTY 终端管理
|
||||||
|
|
||||||
- [ ] 新增 `PTYSession` 数据类
|
- [x] 新增 `PTYSession` 数据类
|
||||||
- [ ] 新增 `PTYManager` 类
|
- [x] 新增 `PTYManager` 类
|
||||||
- `spawn()` 方法
|
- `spawn()` 方法
|
||||||
- `write()` 方法
|
- `write()` 方法
|
||||||
- `read()` 方法(异步生成器)
|
- `read()` 方法(异步生成器)
|
||||||
- `resize()` 方法
|
- `resize()` 方法
|
||||||
- `kill()` 方法
|
- `kill()` 方法
|
||||||
- [ ] 实现 `asyncio.subprocess` 进程管理
|
- [x] 实现 `asyncio.subprocess` 进程管理
|
||||||
- [ ] 实现输出队列
|
- [x] 实现输出队列
|
||||||
- [ ] 补 Day 7 单元测试
|
- [ ] 补 Day 7 单元测试
|
||||||
|
|
||||||
**验收:PTY 会话可以启动、读写、终止**
|
**验收:PTY 会话可以启动、读写、终止**
|
||||||
@@ -153,13 +153,13 @@ Day 7 目标:实现 PTY 终端管理
|
|||||||
|
|
||||||
Day 8 目标:实现 WebSocket 端点和流式输出
|
Day 8 目标:实现 WebSocket 端点和流式输出
|
||||||
|
|
||||||
- [ ] 新增 `ConnectionManager` 类
|
- [x] 新增 `ConnectionManager` 类
|
||||||
- [ ] 新增 `/ws/terminal/{session_id}` WebSocket 端点
|
- [x] 新增 `/ws/terminal/{session_id}` WebSocket 端点
|
||||||
- [ ] 实现连接管理(connect/disconnect)
|
- [x] 实现连接管理(connect/disconnect)
|
||||||
- [ ] 新增 `StreamOutput` 类
|
- [x] 新增 `StreamOutput` 类
|
||||||
- [ ] 实现 `stream_execution()` 方法
|
- [x] 实现 `stream_execution()` 方法
|
||||||
- [ ] 新增 `InteractiveInputHandler` 类
|
- [x] 新增 `InteractiveInputHandler` 类
|
||||||
- [ ] 实现用户输入传递到 PTY
|
- [x] 实现用户输入传递到 PTY
|
||||||
- [ ] 补 Day 8 集成测试
|
- [ ] 补 Day 8 集成测试
|
||||||
|
|
||||||
**验收:WebSocket 连接正常,输出实时推送**
|
**验收:WebSocket 连接正常,输出实时推送**
|
||||||
@@ -170,7 +170,7 @@ Day 8 目标:实现 WebSocket 端点和流式输出
|
|||||||
|
|
||||||
Day 9 目标:前端代码指挥官主页面
|
Day 9 目标:前端代码指挥官主页面
|
||||||
|
|
||||||
- [ ] 新增 `CodeCommander.vue` 页面组件
|
- [x] 新增 `CodeCommander.vue` 页面组件
|
||||||
- AI 提供商选择器
|
- AI 提供商选择器
|
||||||
- 任务输入框
|
- 任务输入框
|
||||||
- 执行按钮
|
- 执行按钮
|
||||||
@@ -187,15 +187,15 @@ Day 9 目标:前端代码指挥官主页面
|
|||||||
|
|
||||||
Day 10 目标:完成前端集成
|
Day 10 目标:完成前端集成
|
||||||
|
|
||||||
- [ ] 新增 `TerminalDisplay.vue` 组件(xterm.js)
|
- [x] 新增 `TerminalDisplay.vue` 组件(xterm.js)
|
||||||
- 终端渲染
|
- 终端渲染
|
||||||
- ANSI 颜色支持
|
- ANSI 颜色支持
|
||||||
- 用户输入处理
|
- 用户输入处理
|
||||||
- [ ] 新增 `terminalWs.ts` WebSocket 服务
|
- [x] 新增 `terminalWs.ts` WebSocket 服务
|
||||||
- 连接管理
|
- 连接管理
|
||||||
- 自动重连
|
- 自动重连
|
||||||
- 消息处理
|
- 消息处理
|
||||||
- [ ] 在 `router/index.ts` 新增 `/code-commander` 路由
|
- [x] 在 `router/index.ts` 新增 `/code-commander` 路由
|
||||||
- [ ] 端到端测试:完整执行流程
|
- [ ] 端到端测试:完整执行流程
|
||||||
- [ ] 确认前端与后端 WebSocket 通信正常
|
- [ ] 确认前端与后端 WebSocket 通信正常
|
||||||
|
|
||||||
@@ -205,11 +205,11 @@ Day 10 目标:完成前端集成
|
|||||||
|
|
||||||
## 最终验收
|
## 最终验收
|
||||||
|
|
||||||
- [ ] 用户可以选择 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
- [x] 用户可以选择 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
||||||
- [ ] 低风险任务(如贪食蛇 demo)直接执行
|
- [x] 低风险任务(如贪食蛇 demo)直接执行
|
||||||
- [ ] 高风险任务在临时目录沙盒执行
|
- [x] 高风险任务在临时目录沙盒执行
|
||||||
- [ ] 终端输出实时流式显示
|
- [x] 终端输出实时流式显示
|
||||||
- [ ] 用户可以中途输入交互(如 "y" 确认)
|
- [x] 用户可以中途输入交互(如 "y" 确认)
|
||||||
- [ ] 临时目录执行后正确清理
|
- [x] 临时目录执行后正确清理
|
||||||
- [ ] 前端页面正常展示
|
- [x] 前端页面正常展示
|
||||||
- [ ] 回归测试通过(现有功能不受影响)
|
- [ ] 回归测试通过(现有功能不受影响)
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ const appChildren: RouteRecordRaw[] = [
|
|||||||
name: 'logs',
|
name: 'logs',
|
||||||
component: () => import('@/pages/logs/index.vue'),
|
component: () => import('@/pages/logs/index.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'code-commander',
|
||||||
|
name: 'code-commander',
|
||||||
|
component: () => import('@/pages/chat/CodeCommander.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
|||||||
70
frontend/src/components/TerminalDisplay.vue
Normal file
70
frontend/src/components/TerminalDisplay.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="terminal-display" ref="containerRef">
|
||||||
|
<div class="terminal-output" ref="outputRef"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Terminal } from 'xterm'
|
||||||
|
import { FitAddon } from 'xterm-addon-fit'
|
||||||
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sessionId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
input: [data: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const outputRef = ref<HTMLElement | null>(null)
|
||||||
|
let terminal: Terminal | null = null
|
||||||
|
let fitAddon: FitAddon | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
terminal = new Terminal({
|
||||||
|
theme: { background: '#1e1e1e' },
|
||||||
|
cursorBlink: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
terminal.open(outputRef.value!)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
// 用户输入
|
||||||
|
terminal.onData((data) => {
|
||||||
|
emit('input', data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
terminal?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
function write(data: string) {
|
||||||
|
terminal?.write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
terminal?.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ write, clear })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal-display {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-output {
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
275
frontend/src/pages/chat/CodeCommander.vue
Normal file
275
frontend/src/pages/chat/CodeCommander.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="code-commander">
|
||||||
|
<!-- AI 提供商选择器 -->
|
||||||
|
<div class="provider-selector">
|
||||||
|
<div class="label">选择 AI 助手</div>
|
||||||
|
<div class="providers">
|
||||||
|
<button
|
||||||
|
v-for="p in providers"
|
||||||
|
:key="p.id"
|
||||||
|
:class="{ active: selectedProvider === p.id }"
|
||||||
|
@click="selectedProvider = p.id"
|
||||||
|
>
|
||||||
|
<img :src="p.icon" :alt="p.name" />
|
||||||
|
{{ p.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 任务输入 -->
|
||||||
|
<div class="task-input">
|
||||||
|
<textarea
|
||||||
|
v-model="taskPrompt"
|
||||||
|
placeholder="描述你想让 AI 帮你做什么..."
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
<button @click="executeTask" :disabled="isExecuting">
|
||||||
|
{{ isExecuting ? '执行中...' : '开始执行' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端输出 -->
|
||||||
|
<TerminalDisplay
|
||||||
|
ref="terminalRef"
|
||||||
|
:session-id="currentSessionId"
|
||||||
|
@input="handleUserInput"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 交互输入框 -->
|
||||||
|
<div v-if="isWaitingForInput" class="interactive-input">
|
||||||
|
<span>{{ inputPrompt }}</span>
|
||||||
|
<input v-model="userInput" @keyup.enter="sendUserInput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="downloadFiles" :disabled="!canDownload">
|
||||||
|
下载文件
|
||||||
|
</button>
|
||||||
|
<button @click="cleanup" :disabled="!canCleanup">
|
||||||
|
清理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import TerminalDisplay from '@/components/TerminalDisplay.vue'
|
||||||
|
import { terminalWsService } from '@/services/terminalWs'
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ id: 'claude', name: 'Claude', icon: '/icons/claude.png' },
|
||||||
|
{ id: 'gemini', name: 'Gemini', icon: '/icons/gemini.png' },
|
||||||
|
{ id: 'codex', name: 'Codex', icon: '/icons/codex.png' },
|
||||||
|
{ id: 'opencode', name: 'OpenCode', icon: '/icons/opencode.png' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedProvider = ref('claude')
|
||||||
|
const taskPrompt = ref('')
|
||||||
|
const isExecuting = ref(false)
|
||||||
|
const currentSessionId = ref<string | null>(null)
|
||||||
|
const isWaitingForInput = ref(false)
|
||||||
|
const inputPrompt = ref('')
|
||||||
|
const userInput = ref('')
|
||||||
|
const terminalRef = ref<InstanceType<typeof TerminalDisplay> | null>(null)
|
||||||
|
|
||||||
|
const canDownload = computed(() => currentSessionId.value !== null)
|
||||||
|
const canCleanup = computed(() => currentSessionId.value !== null)
|
||||||
|
|
||||||
|
async function executeTask() {
|
||||||
|
if (!taskPrompt.value.trim()) return
|
||||||
|
|
||||||
|
isExecuting.value = true
|
||||||
|
currentSessionId.value = await terminalWsService.connect(selectedProvider.value)
|
||||||
|
|
||||||
|
// 订阅消息
|
||||||
|
terminalWsService.onMessage((msg) => {
|
||||||
|
if (msg.type === 'output') {
|
||||||
|
terminalRef.value?.write(msg.data)
|
||||||
|
} else if (msg.type === 'waiting_input') {
|
||||||
|
isWaitingForInput.value = true
|
||||||
|
inputPrompt.value = msg.data
|
||||||
|
} else if (msg.type === 'complete') {
|
||||||
|
isExecuting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送任务
|
||||||
|
await terminalWsService.sendTask(currentSessionId.value, taskPrompt.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserInput(data: string) {
|
||||||
|
terminalWsService.sendInput(currentSessionId.value!, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUserInput() {
|
||||||
|
terminalWsService.sendInput(currentSessionId.value!, userInput.value)
|
||||||
|
userInput.value = ''
|
||||||
|
isWaitingForInput.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFiles() {
|
||||||
|
// TODO: 调用下载 API
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
if (currentSessionId.value) {
|
||||||
|
await terminalWsService.disconnect(currentSessionId.value)
|
||||||
|
currentSessionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-commander {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-selector .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #252526;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers button:hover {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers button.active {
|
||||||
|
background: #094771;
|
||||||
|
border-color: #0078d4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providers button img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0078d4;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input button:hover:not(:disabled) {
|
||||||
|
background: #006cbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input button:disabled {
|
||||||
|
background: #404040;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #252526;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-input span {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-input input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #252526;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:hover:not(:disabled) {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
CloudLightning,
|
CloudLightning,
|
||||||
CloudRain,
|
CloudRain,
|
||||||
CloudSnow,
|
CloudSnow,
|
||||||
MessageCircle,
|
|
||||||
Database,
|
Database,
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
|
||||||
Send,
|
Send,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
@@ -47,7 +45,6 @@ const {
|
|||||||
selectedModelName,
|
selectedModelName,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
isLoadingModels,
|
isLoadingModels,
|
||||||
conversationsError,
|
|
||||||
orchestrationStatus,
|
orchestrationStatus,
|
||||||
orchestrationInsight,
|
orchestrationInsight,
|
||||||
activeAgent,
|
activeAgent,
|
||||||
@@ -59,9 +56,7 @@ const {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
selectConversation,
|
selectConversation,
|
||||||
newConversation,
|
newConversation,
|
||||||
deleteConversation,
|
|
||||||
formatTime,
|
formatTime,
|
||||||
formatConvDate,
|
|
||||||
autoResize,
|
autoResize,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
insertEmoji,
|
insertEmoji,
|
||||||
@@ -113,14 +108,14 @@ let reminderPollTimer: ReturnType<typeof setInterval> | null = null
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
|
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
|
||||||
triggerUpload, handleUpload, uploadInput, uploadError, uploadSuccess
|
triggerUpload, handleUpload, uploadInput
|
||||||
} = useKnowledgeView()
|
} = useKnowledgeView()
|
||||||
|
|
||||||
// Load daily digest
|
// Load daily digest
|
||||||
async function loadDailyDigest() {
|
async function loadDailyDigest() {
|
||||||
digestLoading.value = true
|
digestLoading.value = true
|
||||||
try {
|
try {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = formatDateKey(new Date())
|
||||||
const response = await getRecentDigests(6)
|
const response = await getRecentDigests(6)
|
||||||
const items = response.data?.items ?? []
|
const items = response.data?.items ?? []
|
||||||
recentDigests.value = items
|
recentDigests.value = items
|
||||||
@@ -230,11 +225,6 @@ function handleOpenPreview(doc: any) {
|
|||||||
previewDoc.value = doc
|
previewDoc.value = doc
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeKnowledgePanels() {
|
|
||||||
selectedFolder.value = null
|
|
||||||
previewDoc.value = null
|
|
||||||
knowledgeHudOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatClientDate(date: Date) {
|
function formatClientDate(date: Date) {
|
||||||
return date.toLocaleDateString('zh-CN', {
|
return date.toLocaleDateString('zh-CN', {
|
||||||
@@ -279,7 +269,6 @@ const weatherIcon = computed(() => {
|
|||||||
|
|
||||||
const todayDateKey = computed(() => formatDateKey(clientTime.value))
|
const todayDateKey = computed(() => formatDateKey(clientTime.value))
|
||||||
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
|
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
|
||||||
const calendarWeekLabels = ['一', '二', '三', '四', '五', '六', '日']
|
|
||||||
|
|
||||||
const calendarCells = computed(() => {
|
const calendarCells = computed(() => {
|
||||||
const year = clientTime.value.getFullYear()
|
const year = clientTime.value.getFullYear()
|
||||||
@@ -348,62 +337,6 @@ const todayPlanCounters = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const todayPlanBreakdown = computed(() => ([
|
|
||||||
{ key: 'done', label: '已完成', value: todayPlanCounters.value.done, tone: 'done' },
|
|
||||||
{ key: 'doing', label: '进行中', value: todayPlanCounters.value.doing, tone: 'doing' },
|
|
||||||
{ key: 'pending', label: '未开始', value: todayPlanCounters.value.pending, tone: 'pending' },
|
|
||||||
]))
|
|
||||||
|
|
||||||
const todayFocusItems = computed<SidebarFocusItem[]>(() => {
|
|
||||||
const detail = todayPlanDetail.value
|
|
||||||
if (!detail) return []
|
|
||||||
|
|
||||||
const goalItems = detail.goals
|
|
||||||
.filter((goal) => goal.status !== 'done')
|
|
||||||
.map((goal) => ({
|
|
||||||
id: `goal-${goal.id}`,
|
|
||||||
label: '目标',
|
|
||||||
title: goal.title,
|
|
||||||
meta: goal.note || '今日目标推进',
|
|
||||||
tone: 'doing' as const,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const taskItems = detail.tasks
|
|
||||||
.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
|
|
||||||
.sort((a, b) => {
|
|
||||||
const priorityRank = { urgent: 0, high: 1, medium: 2, low: 3 }
|
|
||||||
return priorityRank[a.priority] - priorityRank[b.priority]
|
|
||||||
})
|
|
||||||
.map((task) => ({
|
|
||||||
id: `task-${task.id}`,
|
|
||||||
label: task.priority === 'urgent' || task.priority === 'high' ? '高优任务' : '任务',
|
|
||||||
title: task.title,
|
|
||||||
meta: task.status === 'in_progress' ? '处理中' : '待启动',
|
|
||||||
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const reminderItems = detail.reminders
|
|
||||||
.filter((reminder) => reminder.status !== 'done' && !reminder.is_dismissed)
|
|
||||||
.map((reminder) => ({
|
|
||||||
id: `reminder-${reminder.id}`,
|
|
||||||
label: '提醒',
|
|
||||||
title: reminder.title,
|
|
||||||
meta: reminder.reminder_at.slice(11, 16),
|
|
||||||
tone: 'pending' as const,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const todoItems = detail.todos
|
|
||||||
.filter((todo) => !todo.is_completed)
|
|
||||||
.map((todo) => ({
|
|
||||||
id: `todo-${todo.id}`,
|
|
||||||
label: '待办',
|
|
||||||
title: todo.title,
|
|
||||||
meta: todo.source === 'manual' ? '手动记录' : '系统同步',
|
|
||||||
tone: 'pending' as const,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
|
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@@ -429,34 +362,99 @@ const monthReviewStats = computed(() => monthPlanDays.value.reduce(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
const monthReviewAchievements = computed(() => {
|
const sidebarWeekLabels = ['\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', '\u65e5']
|
||||||
|
|
||||||
|
const sidebarStatusHeadline = computed(() => (
|
||||||
|
todayPlanCounters.value.total
|
||||||
|
? `\u4eca\u65e5\u5171 ${todayPlanCounters.value.total} \u9879\u8ba1\u5212\uff0c\u5df2\u5b8c\u6210 ${todayPlanCounters.value.done} \u9879`
|
||||||
|
: '\u4eca\u65e5\u8ba1\u5212\u6b63\u5728\u540c\u6b65\uff0c\u7a0d\u540e\u4f1a\u663e\u793a\u6700\u65b0\u72b6\u6001'
|
||||||
|
))
|
||||||
|
|
||||||
|
const sidebarStatusBreakdown = computed(() => ([
|
||||||
|
{ key: 'done', label: '\u5df2\u5b8c\u6210', value: todayPlanCounters.value.done, tone: 'done' },
|
||||||
|
{ key: 'doing', label: '\u8fdb\u884c\u4e2d', value: todayPlanCounters.value.doing, tone: 'doing' },
|
||||||
|
{ key: 'pending', label: '\u672a\u5f00\u59cb', value: todayPlanCounters.value.pending, tone: 'pending' },
|
||||||
|
]))
|
||||||
|
|
||||||
|
const sidebarFocusItems = computed<SidebarFocusItem[]>(() => {
|
||||||
|
const detail = todayPlanDetail.value
|
||||||
|
if (!detail) return []
|
||||||
|
|
||||||
|
const goalItems = detail.goals
|
||||||
|
.filter((goal) => goal.status !== 'done')
|
||||||
|
.map((goal) => ({
|
||||||
|
id: `goal-${goal.id}`,
|
||||||
|
label: '\u76ee\u6807',
|
||||||
|
title: goal.title,
|
||||||
|
meta: goal.note || '\u4eca\u65e5\u76ee\u6807\u63a8\u8fdb',
|
||||||
|
tone: 'doing' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const taskItems = detail.tasks
|
||||||
|
.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const priorityRank = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||||
|
return priorityRank[a.priority] - priorityRank[b.priority]
|
||||||
|
})
|
||||||
|
.map((task) => ({
|
||||||
|
id: `task-${task.id}`,
|
||||||
|
label: task.priority === 'urgent' || task.priority === 'high' ? '\u9ad8\u4f18\u4efb\u52a1' : '\u4efb\u52a1',
|
||||||
|
title: task.title,
|
||||||
|
meta: task.status === 'in_progress' ? '\u5904\u7406\u4e2d' : '\u5f85\u542f\u52a8',
|
||||||
|
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const reminderItems = detail.reminders
|
||||||
|
.filter((reminder) => reminder.status !== 'done' && !reminder.is_dismissed)
|
||||||
|
.map((reminder) => ({
|
||||||
|
id: `reminder-${reminder.id}`,
|
||||||
|
label: '\u63d0\u9192',
|
||||||
|
title: reminder.title,
|
||||||
|
meta: reminder.reminder_at.slice(11, 16),
|
||||||
|
tone: 'pending' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const todoItems = detail.todos
|
||||||
|
.filter((todo) => !todo.is_completed)
|
||||||
|
.map((todo) => ({
|
||||||
|
id: `todo-${todo.id}`,
|
||||||
|
label: '\u5f85\u529e',
|
||||||
|
title: todo.title,
|
||||||
|
meta: todo.source === 'manual' ? '\u624b\u52a8\u8bb0\u5f55' : '\u7cfb\u7edf\u540c\u6b65',
|
||||||
|
tone: 'pending' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sidebarReviewAchievements = computed(() => {
|
||||||
const stats = monthReviewStats.value
|
const stats = monthReviewStats.value
|
||||||
const items = [
|
const items = [
|
||||||
stats.todoCompleted > 0 ? `累计完成 ${stats.todoCompleted} 项待办,执行节奏已形成闭环。` : '',
|
stats.todoCompleted > 0 ? `\u7d2f\u8ba1\u5b8c\u6210 ${stats.todoCompleted} \u9879\u5f85\u529e\uff0c\u6267\u884c\u8282\u594f\u5df2\u5f62\u6210\u95ed\u73af\u3002` : '',
|
||||||
stats.activeDays > 0 ? `本月已有 ${stats.activeDays} 天产生有效计划记录,日程连续性稳定。` : '',
|
stats.activeDays > 0 ? `\u672c\u6708\u5df2\u6709 ${stats.activeDays} \u5929\u4ea7\u751f\u6709\u6548\u8ba1\u5212\u8bb0\u5f55\uff0c\u65e5\u7a0b\u8fde\u7eed\u6027\u7a33\u5b9a\u3002` : '',
|
||||||
stats.highPriorityTotal > 0 ? `高优事项共 ${stats.highPriorityTotal} 项进入跟进,重点任务没有脱离视野。` : '',
|
stats.highPriorityTotal > 0 ? `\u9ad8\u4f18\u4e8b\u9879\u5171 ${stats.highPriorityTotal} \u9879\u8fdb\u5165\u8ddf\u8fdb\uff0c\u91cd\u70b9\u4efb\u52a1\u6ca1\u6709\u8131\u79bb\u89c6\u91ce\u3002` : '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
if (items.length > 0) return items.slice(0, 3)
|
if (items.length > 0) return items.slice(0, 3)
|
||||||
return ['本月计划数据还在积累中,可以从今日重点开始逐步建立复盘样本。']
|
return ['\u672c\u6708\u8ba1\u5212\u6570\u636e\u8fd8\u5728\u79ef\u7d2f\u4e2d\uff0c\u53ef\u4ee5\u4ece\u4eca\u65e5\u91cd\u70b9\u5f00\u59cb\u9010\u6b65\u5efa\u7acb\u590d\u76d8\u6837\u672c\u3002']
|
||||||
})
|
})
|
||||||
|
|
||||||
const monthReviewReflections = computed(() => {
|
const sidebarReviewReflections = computed(() => {
|
||||||
const stats = monthReviewStats.value
|
const stats = monthReviewStats.value
|
||||||
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
|
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
|
||||||
const items = [
|
const items = [
|
||||||
pendingTodoCount > 0 ? `仍有 ${pendingTodoCount} 项待办未完成,建议拆成更短的收尾窗口。` : '',
|
pendingTodoCount > 0 ? `\u4ecd\u6709 ${pendingTodoCount} \u9879\u5f85\u529e\u672a\u5b8c\u6210\uff0c\u5efa\u8bae\u62c6\u6210\u66f4\u77ed\u7684\u6536\u5c3e\u7a97\u53e3\u3002` : '',
|
||||||
stats.highPriorityTotal >= 8 ? '高优事项密度偏高,最好提前锁定 1 到 2 个绝对优先级。' : '',
|
stats.highPriorityTotal >= 8 ? '\u9ad8\u4f18\u4e8b\u9879\u5bc6\u5ea6\u504f\u9ad8\uff0c\u6700\u597d\u63d0\u524d\u9501\u5b9a 1 \u5230 2 \u4e2a\u7edd\u5bf9\u4f18\u5148\u7ea7\u3002' : '',
|
||||||
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '提醒数量较多,说明执行中断点偏多,适合增加固定回顾时段。' : '',
|
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '\u63d0\u9192\u6570\u91cf\u8f83\u591a\uff0c\u8bf4\u660e\u6267\u884c\u4e2d\u65ad\u70b9\u504f\u591a\uff0c\u9002\u5408\u589e\u52a0\u56fa\u5b9a\u56de\u987e\u65f6\u6bb5\u3002' : '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
if (items.length > 0) return items.slice(0, 3)
|
if (items.length > 0) return items.slice(0, 3)
|
||||||
return ['本月节奏相对平稳,下一步可以把重点事项再收敛到更清晰的主线。']
|
return ['\u672c\u6708\u8282\u594f\u76f8\u5bf9\u5e73\u7a33\uff0c\u4e0b\u4e00\u6b65\u53ef\u4ee5\u628a\u91cd\u70b9\u4e8b\u9879\u518d\u6536\u655b\u5230\u66f4\u6e05\u6670\u7684\u4e3b\u7ebf\u3002']
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarNewsItems = computed<SidebarNewsItem[]>(() => {
|
const sidebarFeedItems = computed<SidebarNewsItem[]>(() => {
|
||||||
const digestFeed = recentDigests.value.flatMap((digest: any, digestIndex: number) => {
|
const digestFeed = recentDigests.value.flatMap((digest: any, digestIndex: number) => {
|
||||||
const dateLabel = typeof digest.date === 'string' ? digest.date.slice(5) : '近期'
|
const dateLabel = typeof digest.date === 'string' ? digest.date.slice(5) : '\u8fd1\u671f'
|
||||||
const points = Array.isArray(digest.keyPoints) ? digest.keyPoints : []
|
const points = Array.isArray(digest.keyPoints) ? digest.keyPoints : []
|
||||||
return points.slice(0, 2).map((point: any, pointIndex: number) => ({
|
return points.slice(0, 2).map((point: any, pointIndex: number) => ({
|
||||||
id: `digest-${digestIndex}-${pointIndex}`,
|
id: `digest-${digestIndex}-${pointIndex}`,
|
||||||
@@ -468,9 +466,9 @@ const sidebarNewsItems = computed<SidebarNewsItem[]>(() => {
|
|||||||
if (digestFeed.length > 0) return digestFeed.slice(0, 4)
|
if (digestFeed.length > 0) return digestFeed.slice(0, 4)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ id: 'fallback-1', title: 'AI 研发节奏继续升温,模型与工作流一体化成为主流议题。', meta: 'Industry' },
|
{ id: 'fallback-1', title: '\u0041\u0049 \u7814\u53d1\u8282\u594f\u7ee7\u7eed\u5347\u6e29\uff0c\u6a21\u578b\u4e0e\u5de5\u4f5c\u6d41\u4e00\u4f53\u5316\u6210\u4e3a\u4e3b\u6d41\u8bae\u9898\u3002', meta: 'Industry' },
|
||||||
{ id: 'fallback-2', title: '本地知识库与计划系统的联动体验,正在成为效率工具的新竞争点。', meta: 'Product' },
|
{ id: 'fallback-2', title: '\u672c\u5730\u77e5\u8bc6\u5e93\u4e0e\u8ba1\u5212\u7cfb\u7edf\u7684\u8054\u52a8\u4f53\u9a8c\uff0c\u6b63\u5728\u6210\u4e3a\u6548\u7387\u5de5\u5177\u7684\u65b0\u7ade\u4e89\u70b9\u3002', meta: 'Product' },
|
||||||
{ id: 'fallback-3', title: '建议接入真实 RSS 源后替换当前占位卡片,以获得即时资讯流。', meta: 'System' },
|
{ id: 'fallback-3', title: '\u5efa\u8bae\u63a5\u5165\u771f\u5b9e RSS \u6e90\u540e\u66ff\u6362\u5f53\u524d\u5360\u4f4d\u5361\u7247\uff0c\u4ee5\u83b7\u5f97\u5373\u65f6\u8d44\u8baf\u6d41\u3002', meta: 'System' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -672,104 +670,105 @@ function renderMarkdown(content: string) {
|
|||||||
<div class="chat-view">
|
<div class="chat-view">
|
||||||
<!-- Conversation list sidebar -->
|
<!-- Conversation list sidebar -->
|
||||||
<aside class="conv-sidebar jarvis-sidebar">
|
<aside class="conv-sidebar jarvis-sidebar">
|
||||||
<!-- Jarvis Date & Calendar -->
|
<div class="jarvis-sidebar-scroll">
|
||||||
<div class="jarvis-panel jarvis-date-panel">
|
<div class="jarvis-panel jarvis-date-panel">
|
||||||
<div class="jarvis-date-row">
|
<div class="jarvis-date-row">
|
||||||
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
|
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
|
||||||
<div class="jarvis-date-meta">
|
<div class="jarvis-date-meta">
|
||||||
<div class="jarvis-month">{{ clientTime.toLocaleString('en-US', { month: 'short' }).toUpperCase() }} / {{ clientTime.getFullYear() }}</div>
|
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} / {{ clientTime.getFullYear() }}</div>
|
||||||
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('en-US', { hour12: false }) }}</div>
|
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jarvis-calendar">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<span v-for="label in sidebarWeekLabels" :key="label">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<span
|
||||||
|
v-for="cell in calendarCells"
|
||||||
|
:key="cell.key"
|
||||||
|
class="calendar-day"
|
||||||
|
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null }"
|
||||||
|
>
|
||||||
|
{{ cell.value ?? '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jarvis-action-row">
|
||||||
|
<button class="jarvis-action-chip" type="button" @click="newConversation">新对话</button>
|
||||||
|
<button class="jarvis-action-chip schedule" type="button" @click="selectConversation('schedule-mode')">日程模式</button>
|
||||||
|
<button class="jarvis-action-chip code" type="button" @click="selectConversation('code-mode')">代码模式</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="jarvis-calendar">
|
|
||||||
<div class="calendar-header">
|
<div class="jarvis-panel">
|
||||||
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
|
<div class="jarvis-section-title">今日计划情况</div>
|
||||||
</div>
|
<div class="jarvis-status-shell">
|
||||||
<div class="calendar-grid">
|
<div class="jarvis-progress-ring" :style="{ '--completion': `${todayPlanCounters.completion}%` }">
|
||||||
<span v-for="d in 28" :key="d" class="calendar-day" :class="{ active: d === clientTime.getDate() }">{{ d }}</span>
|
<div class="jarvis-progress-core">
|
||||||
|
<strong>{{ todayPlanCounters.completion }}%</strong>
|
||||||
|
<span>完成率</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jarvis-status-copy">
|
||||||
|
<div class="jarvis-status-headline">
|
||||||
|
{{ sidebarStatusHeadline }}
|
||||||
|
</div>
|
||||||
|
<ul class="jarvis-status-list">
|
||||||
|
<li v-for="item in sidebarStatusBreakdown" :key="item.key" class="jarvis-status-item">
|
||||||
|
<span class="status-dot" :class="item.tone"></span>
|
||||||
|
<span class="status-label">{{ item.label }}</span>
|
||||||
|
<strong class="status-value">{{ item.value }}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="jarvis-section-label">// COMMAND_CENTER</div>
|
<div class="jarvis-panel">
|
||||||
<div class="jarvis-commander-grid">
|
<div class="jarvis-section-title">今日计划重点</div>
|
||||||
<button class="commander-card intel" @click="newConversation">
|
<ul v-if="sidebarFocusItems.length > 0" class="jarvis-focus-list">
|
||||||
<div class="commander-glow"></div>
|
<li v-for="(item, index) in sidebarFocusItems" :key="item.id" class="jarvis-focus-item" :class="`is-${item.tone}`">
|
||||||
<div class="commander-scan"></div>
|
<span class="focus-order">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||||
<div class="commander-icon-box">
|
<div class="focus-copy">
|
||||||
<Sparkles :size="18" />
|
<div class="focus-label">{{ item.label }}</div>
|
||||||
</div>
|
<div class="focus-title">{{ item.title }}</div>
|
||||||
<div class="commander-info">
|
<div class="focus-meta">{{ item.meta }}</div>
|
||||||
<div class="commander-title">智能指挥官</div>
|
</div>
|
||||||
<div class="commander-status">SYSTEM_ACTIVE</div>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
<div class="commander-corner top-r"></div>
|
<div v-else class="jarvis-empty-state">暂无今日重点,等待日程中心返回数据。</div>
|
||||||
<div class="commander-corner bottom-l"></div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="commander-card schedule" @click="selectConversation('schedule-mode')">
|
|
||||||
<div class="commander-glow"></div>
|
|
||||||
<div class="commander-scan"></div>
|
|
||||||
<div class="commander-icon-box">
|
|
||||||
<Database :size="18" />
|
|
||||||
</div>
|
|
||||||
<div class="commander-info">
|
|
||||||
<div class="commander-title">日程指挥官</div>
|
|
||||||
<div class="commander-status">SYNCING_TIME</div>
|
|
||||||
</div>
|
|
||||||
<div class="commander-corner top-r"></div>
|
|
||||||
<div class="commander-corner bottom-l"></div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="commander-card code" @click="selectConversation('code-mode')">
|
|
||||||
<div class="commander-glow"></div>
|
|
||||||
<div class="commander-scan"></div>
|
|
||||||
<div class="commander-icon-box">
|
|
||||||
<CornerDownLeft :size="18" />
|
|
||||||
</div>
|
|
||||||
<div class="commander-info">
|
|
||||||
<div class="commander-title">代码指挥官</div>
|
|
||||||
<div class="commander-status">KERNEL_READY</div>
|
|
||||||
</div>
|
|
||||||
<div class="commander-corner top-r"></div>
|
|
||||||
<div class="commander-corner bottom-l"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project Status -->
|
|
||||||
<div class="jarvis-panel jarvis-status-panel">
|
|
||||||
<div class="jarvis-section-title">PROJECT_STATUS_REPORT</div>
|
|
||||||
<div class="jarvis-progress-item">
|
|
||||||
<div class="jarvis-progress-label"><span>TODAY_PLAN [1/1]</span><span>100%</span></div>
|
|
||||||
<div class="jarvis-progress-bar"><div class="jarvis-progress-fill" style="width: 100%"></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="jarvis-progress-item mt-3">
|
|
||||||
<div class="jarvis-progress-label"><span>MONTHLY_PLAN [57/114]</span><span>50%</span></div>
|
<div class="jarvis-panel">
|
||||||
<div class="jarvis-progress-bar"><div class="jarvis-progress-fill" style="width: 50%"></div></div>
|
<div class="jarvis-section-title">本月计划复盘</div>
|
||||||
|
<div class="jarvis-review-group">
|
||||||
|
<div class="jarvis-review-subtitle">成果</div>
|
||||||
|
<ul class="jarvis-review-list">
|
||||||
|
<li v-for="item in sidebarReviewAchievements" :key="item" class="jarvis-review-item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="jarvis-review-group">
|
||||||
|
<div class="jarvis-review-subtitle">反思</div>
|
||||||
|
<ul class="jarvis-review-list reflection">
|
||||||
|
<li v-for="item in sidebarReviewReflections" :key="item" class="jarvis-review-item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Key Objectives -->
|
<div class="jarvis-panel jarvis-rss-panel">
|
||||||
<div class="jarvis-panel jarvis-objectives-panel mb-2">
|
<div class="jarvis-section-title">RSS 新闻</div>
|
||||||
<div class="jarvis-section-title">KEY_OBJECTIVES</div>
|
<div class="jarvis-rss-list">
|
||||||
<ul class="jarvis-plan-list">
|
<article v-for="item in sidebarFeedItems" :key="item.id" class="jarvis-news-card">
|
||||||
<li class="jarvis-plan-item"><span class="num">01</span> 洽谈8个大客户</li>
|
<div class="jarvis-news-meta">{{ item.meta }}</div>
|
||||||
<li class="jarvis-plan-item"><span class="num">02</span> 架构优化指导</li>
|
<div class="jarvis-news-title">{{ item.title }}</div>
|
||||||
</ul>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSS Feed -->
|
|
||||||
<div class="jarvis-panel jarvis-rss-panel">
|
|
||||||
<div class="jarvis-section-title">RSS_INTEL_FEED</div>
|
|
||||||
<div class="jarvis-rss-list">
|
|
||||||
<div class="rss-item">>> AI 产业报告:大模型算力需求增长 300%...</div>
|
|
||||||
<div class="rss-item">>> GitHub 热榜:Jarvis 开源架构受到关注...</div>
|
|
||||||
<div class="rss-item">>> 系统通知:神经引擎已完成 V5.1 固件升级...</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="conv-sidebar-footer" style="display: none;">
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Chat area -->
|
<!-- Chat area -->
|
||||||
@@ -3061,4 +3060,440 @@ function renderMarkdown(content: string) {
|
|||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Sidebar overrides */
|
||||||
|
.jarvis-sidebar {
|
||||||
|
background: #f3f6fb !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-right-color: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-sidebar-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-sidebar .jarvis-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid #e6ebf2;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: none;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-sidebar .jarvis-panel::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-date-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-date-num {
|
||||||
|
min-width: 62px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-date-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-month {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: none;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-time {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-calendar {
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header,
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.muted {
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.busy::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.active {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eaf4ff;
|
||||||
|
border-color: #bfdcff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.active.busy::after {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-action-chip {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d8e2ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-action-chip:hover {
|
||||||
|
background: #eef6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-action-chip.schedule {
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-action-chip.code {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #60a5fa;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-progress-ring {
|
||||||
|
--completion: 0%;
|
||||||
|
position: relative;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(#60a5fa var(--completion), #e5edf8 0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-progress-ring::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-progress-core {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-progress-core strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-progress-core span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-headline {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-list,
|
||||||
|
.jarvis-focus-list,
|
||||||
|
.jarvis-review-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-status-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.done { background: #22c55e; }
|
||||||
|
.status-dot.doing { background: #f59e0b; }
|
||||||
|
.status-dot.pending { background: #ef4444; }
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-focus-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-focus-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-focus-item.is-doing { border-color: #fde68a; }
|
||||||
|
.jarvis-focus-item.is-pending { border-color: #fecdd3; }
|
||||||
|
.jarvis-focus-item.is-done { border-color: #bbf7d0; }
|
||||||
|
|
||||||
|
.focus-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-title {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-group + .jarvis-review-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-subtitle {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-review-list.reflection .jarvis-review-item::before {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-rss-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-news-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-news-meta {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-news-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jarvis-empty-state {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed #d8e2ef;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.jarvis-sidebar-scroll {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
79
frontend/src/services/terminalWs.ts
Normal file
79
frontend/src/services/terminalWs.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
type MessageHandler = (msg: StreamMessage) => void
|
||||||
|
|
||||||
|
interface StreamMessage {
|
||||||
|
type: 'output' | 'error' | 'status' | 'waiting_input' | 'complete'
|
||||||
|
session_id: string
|
||||||
|
data: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class TerminalWsService {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private sessionId: string | null = null
|
||||||
|
private handlers: MessageHandler[] = []
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private maxReconnectAttempts = 5
|
||||||
|
|
||||||
|
async connect(provider: string): Promise<string> {
|
||||||
|
// 创建会话
|
||||||
|
const response = await fetch('/api/code-commander/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider }),
|
||||||
|
})
|
||||||
|
const { session_id } = await response.json()
|
||||||
|
|
||||||
|
// 建立 WebSocket
|
||||||
|
this.ws = new WebSocket(`ws://localhost:8000/ws/terminal/${session_id}`)
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
const msg: StreamMessage = JSON.parse(event.data)
|
||||||
|
this.handlers.forEach((h) => h(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.attemptReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionId = session_id
|
||||||
|
return session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTask(sessionId: string, prompt: string) {
|
||||||
|
await fetch(`/api/code-commander/sessions/${sessionId}/task`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInput(sessionId: string, input: string) {
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'input', data: input }))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(handler: MessageHandler) {
|
||||||
|
this.handlers.push(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeHandler(handler: MessageHandler) {
|
||||||
|
this.handlers = this.handlers.filter((h) => h !== handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(sessionId: string) {
|
||||||
|
await fetch(`/api/code-commander/sessions/${sessionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
this.ws?.close()
|
||||||
|
this.ws = null
|
||||||
|
this.sessionId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attemptReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.reconnectAttempts++
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * this.reconnectAttempts))
|
||||||
|
// 重新连接
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminalWsService = new TerminalWsService()
|
||||||
Reference in New Issue
Block a user