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:
2026-04-05 14:56:45 +08:00
parent 11160ec4d2
commit 5667190abe
22 changed files with 2641 additions and 347 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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,
} }

View File

@@ -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()
) )

View File

@@ -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

View File

@@ -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(

View File

@@ -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",
]

View 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

View 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="",
)

View 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]

View 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)

View 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)),
}

View 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)

View 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()

View File

@@ -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")

View File

@@ -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

View 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)

View File

@@ -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] 前端页面正常展示
- [ ] 回归测试通过(现有功能不受影响) - [ ] 回归测试通过(现有功能不受影响)

View File

@@ -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[] = [

View 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>

View 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>

View File

@@ -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">&#x65B0;&#x5BF9;&#x8BDD;</button>
<button class="jarvis-action-chip schedule" type="button" @click="selectConversation('schedule-mode')">&#x65E5;&#x7A0B;&#x6A21;&#x5F0F;</button>
<button class="jarvis-action-chip code" type="button" @click="selectConversation('code-mode')">&#x4EE3;&#x7801;&#x6A21;&#x5F0F;</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">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x60C5;&#x51B5;</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>&#x5B8C;&#x6210;&#x7387;</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">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x91CD;&#x70B9;</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">&#x6682;&#x65E0;&#x4ECA;&#x65E5;&#x91CD;&#x70B9;&#xFF0C;&#x7B49;&#x5F85;&#x65E5;&#x7A0B;&#x4E2D;&#x5FC3;&#x8FD4;&#x56DE;&#x6570;&#x636E;&#x3002;</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">&#x672C;&#x6708;&#x8BA1;&#x5212;&#x590D;&#x76D8;</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">&#x6210;&#x679C;</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">&#x53CD;&#x601D;</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 &#x65B0;&#x95FB;</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>

View 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()