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 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
## 允许使用的工具:
## 你的允许使用的工具:
- get_tasks
- get_forum_posts
- search_knowledge
- hybrid_search
- web_search
## 要求:
## 你的要求:
- 先给结论与判断
- 再说明依据与建议
- 当需要外部/最新信息时,可使用 `web_search`
@@ -324,6 +324,38 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
"""
CODE_COMMANDER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是代码指挥官,负责协调 AI 写代码助手。
## 你的职责:
1. 接收用户选择的 AI 提供商Claude/Gemini/Codex/OpenCode
2. 接收用户的写代码需求
3. 进行安全分级判定
4. 路由到合适的执行器
## 安全分级规则:
- 低风险demo、示例、贪食蛇游戏等独立项目
- 高风险:修改现有项目、涉及 Jarvis 项目、路径操作等
## 执行模式:
- 直接执行:低风险任务,直接运行
- 沙盒执行:高风险任务,在临时目录隔离执行
## 你的输出:
- 简洁汇报执行结果
- 如果需要用户交互(如确认 "y"),明确提示
"""
SANDBOX_EXECUTION_PROMPT = """将在隔离的临时目录中执行任务。
任务完成后,工作目录会被保留供下载。"""
DIRECT_EXECUTION_PROMPT = """将直接执行任务。
如果需要交互,请等待用户输入。"""
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
@@ -382,6 +414,7 @@ TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY = {
"executor": EXECUTOR_SYSTEM_PROMPT,
"librarian": LIBRARIAN_SYSTEM_PROMPT,
"analyst": ANALYST_SYSTEM_PROMPT,
"code_commander": CODE_COMMANDER_SYSTEM_PROMPT,
}

View File

@@ -29,6 +29,7 @@ TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS: dict[str, tuple[str, ...]] = {
"analyst_progress",
"analyst_insights",
),
AgentRole.CODE_COMMANDER.value: (),
}
TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
@@ -37,6 +38,7 @@ TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = {
AgentRole.EXECUTOR.value: "Executor",
AgentRole.LIBRARIAN.value: "Librarian",
AgentRole.ANALYST.value: "Analyst",
AgentRole.CODE_COMMANDER.value: "Code Commander",
}
TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
@@ -55,6 +57,9 @@ TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
AgentRole.ANALYST.value: (
"Handle reporting and insight requests using analyst sub-commanders.",
),
AgentRole.CODE_COMMANDER.value: (
"Handle code writing and execution tasks using AI CLI adapters.",
),
}
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
@@ -63,11 +68,13 @@ TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
AgentRole.EXECUTOR.value,
AgentRole.LIBRARIAN.value,
AgentRole.ANALYST.value,
AgentRole.CODE_COMMANDER.value,
),
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
AgentRole.CODE_COMMANDER.value: (),
}
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
@@ -99,11 +106,7 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
_capability_tool_names = tuple(
dict.fromkeys(
tool.name
for tools in SUB_COMMANDER_TOOLSETS.values()
for tool in tools
)
dict.fromkeys(tool.name for tools in SUB_COMMANDER_TOOLSETS.values() for tool in tools)
)
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
@@ -260,9 +263,7 @@ BUILTIN_SUB_COMMANDER_MANIFESTS: tuple[SubCommanderManifest, ...] = tuple(
sub_commander_id=sub_commander_id,
parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id],
prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id],
capability_ids=list(
dict.fromkeys(tool.name for tool in tools)
),
capability_ids=list(dict.fromkeys(tool.name for tool in tools)),
)
for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items()
)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Literal
from uuid import uuid4
from pydantic import BaseModel, Field
@@ -13,6 +15,18 @@ InterruptStatus = Literal["requested", "acknowledged", "resolved"]
BudgetMode = Literal["direct", "collaboration"]
class CodeProviderType(str, Enum):
CLAUDE = "claude"
GEMINI = "gemini"
CODEX = "codex"
OPENCODE = "opencode"
class RiskLevelType(str, Enum):
LOW = "low"
HIGH = "high"
class InterruptRecord(BaseModel):
interrupt_id: str
reason: str
@@ -83,3 +97,37 @@ class TaskResult(BaseModel):
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
next_action: str | None = None
output_data: dict[str, Any] | None = None
class CodeTaskType(str, Enum):
DEMO = "demo"
PROJECT = "project"
MODIFICATION = "modification"
class CodeTask(BaseModel):
"""代码任务请求模型"""
task_id: str = Field(default_factory=lambda: str(uuid4()))
task_type: CodeTaskType
ai_provider: CodeProviderType
sandbox_mode: bool = False
workspace_path: str | None = None
user_prompt: str
parent_task_id: str | None = None
thread_id: str | None = None
message_id: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class CodeExecutionResultSchema(BaseModel):
"""代码执行结果模型 (API 响应用)"""
success: bool
message: str
files_created: list[str] = Field(default_factory=list)
output: str = ""
error: str | None = None
exit_code: int = 0
execution_time: float | None = None
sandbox_session_id: str | None = None

View File

@@ -4,7 +4,14 @@ from typing import Annotated, Any, Literal, TypedDict
from app.agents.schemas.event import AgentEvent
from app.agents.schemas.message import AgentMessage
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult, VerificationStatus
from app.agents.schemas.task import (
AgentTask,
CollaborationBudget,
InterruptRecord,
RecoveryRecord,
TaskResult,
VerificationStatus,
)
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from langgraph.graph.message import add_messages
@@ -23,6 +30,7 @@ class AgentRole(str, Enum):
EXECUTOR = "executor"
LIBRARIAN = "librarian"
ANALYST = "analyst"
CODE_COMMANDER = "code_commander"
@dataclass
@@ -141,6 +149,14 @@ class AgentState(TypedDict):
user_llm_config: dict[str, Any] | None
provider_capabilities: dict[str, Any] | None
# Code Commander state
code_task_type: Literal["demo", "project", "modification"] | None
code_ai_provider: Literal["claude", "gemini", "codex", "opencode"] | None
code_sandbox_mode: bool | None
code_workspace_path: str | None
code_execution_session_id: str | None
code_execution_result: dict[str, Any] | None
def initial_state(user_id: str, conversation_id: str) -> AgentState:
return AgentState(

View File

@@ -138,3 +138,12 @@ SUB_COMMANDER_TOOLSETS = {
"analyst_progress": ANALYST_PROGRESS_TOOLS,
"analyst_insights": ANALYST_INSIGHT_TOOLS,
}
# Code Commander toolset (tools implemented in later phases)
CODE_COMMANDER_TOOLSET_NAMES = [
"execute_code_task",
"get_execution_status",
"send_interactive_input",
"download_workspace",
"cleanup_workspace",
]

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,
agent_skills_router,
agent_sessions_router,
terminal_router,
)
from app.routers.scheduler import router as scheduler_router
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
@@ -127,6 +128,7 @@ app.include_router(plugins_router)
app.include_router(marketplace_router)
app.include_router(agent_skills_router)
app.include_router(agent_sessions_router)
app.include_router(terminal_router)
@app.get("/api/health")

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.agent_skills import router as agent_skills_router
from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router

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 的基础架子
- [ ] 新增 `CODE_COMMANDER = "code_commander"``AgentRole` 枚举
- [ ] 新增 `CodeCommanderState` TypedDict包含 task_type, ai_provider, sandbox_mode 等)
- [ ] 新增 `CODE_COMMANDER_SYSTEM_PROMPT` 系统提示
- [ ] 新增 `SANDBOX_EXECUTION_PROMPT` 沙盒执行说明
- [ ] 新增 `DIRECT_EXECUTION_PROMPT` 直接执行说明
- [ ]`SUB_COMMANDER_TOOLSETS` 中注册 `CODE_COMMANDER_TOOLSET`
- [ ] 新增 `CodeCommanderManifest``AGENT_MANIFESTS`
- [x] 新增 `CODE_COMMANDER = "code_commander"``AgentRole` 枚举
- [x] 新增 `CodeCommanderState` TypedDict包含 task_type, ai_provider, sandbox_mode 等)
- [x] 新增 `CODE_COMMANDER_SYSTEM_PROMPT` 系统提示
- [x] 新增 `SANDBOX_EXECUTION_PROMPT` 沙盒执行说明
- [x] 新增 `DIRECT_EXECUTION_PROMPT` 直接执行说明
- [x]`SUB_COMMANDER_TOOLSETS` 中注册 `CODE_COMMANDER_TOOLSET`
- [x] 新增 `CodeCommanderManifest``AGENT_MANIFESTS`
- [ ] 补 Phase 1 单元测试
**验收:确认 `AgentRole.CODE_COMMANDER` 存在且值正确**
@@ -39,17 +39,17 @@ Day 1 目标:完成代码指挥官 Agent 的基础架子
Day 2 目标:实现适配不同 AI CLI 的统一接口
- [ ] 新增 `AICLIAdapter` 抽象基类
- [x] 新增 `AICLIAdapter` 抽象基类
- `cli_name` 属性
- `requires_workspace` 属性
- `build_command()` 方法
- `parse_output()` 方法
- `is_installed()` 方法
- [ ] 新增 `ClaudeAdapter` 实现
- [ ] 新增 `GeminiAdapter` 实现
- [ ] 新增 `CodexAdapter` 实现
- [ ] 新增 `OpenCodeAdapter` 实现
- [ ] 新增 `CodeExecutionResult` 数据类
- [x] 新增 `ClaudeAdapter` 实现
- [x] 新增 `GeminiAdapter` 实现
- [x] 新增 `CodexAdapter` 实现
- [x] 新增 `OpenCodeAdapter` 实现
- [x] 新增 `CodeExecutionResult` 数据类
- [ ] 补 Day 2 单元测试
**验收:`AICLIAdapter` 可以正确识别 4 种 CLI**
@@ -60,13 +60,13 @@ Day 2 目标:实现适配不同 AI CLI 的统一接口
Day 3 目标:实现安全分级和直接执行器
- [ ] 新增 `RiskLevel` 枚举LOW/HIGH
- [ ] 新增 `SecurityClassifier`
- [x] 新增 `RiskLevel` 枚举LOW/HIGH
- [x] 新增 `SecurityClassifier`
- `HIGH_RISK_KEYWORDS` 列表
- `LOW_RISK_KEYWORDS` 列表
- `classify()` 方法实现
- `_is_project_path()` 方法实现
- [ ] 新增 `DirectExecutor`
- [x] 新增 `DirectExecutor`
- `execute()` 方法(异步)
- 超时控制
- `is_installed()` 检查
@@ -80,16 +80,16 @@ Day 3 目标:实现安全分级和直接执行器
Day 4 目标:实现沙盒执行器
- [ ] 新增 `SandboxEnvironment`
- [x] 新增 `SandboxEnvironment`
- `create()` 静态方法(创建临时目录)
- `cleanup()` 方法
- `workspace_path` 属性
- `session_id` 属性
- [ ] 新增 `SandboxExecutor`
- [x] 新增 `SandboxExecutor`
- `execute()` 方法异步yield 流式输出)
- `cleanup_session()` 方法
- `_list_created_files()` 方法
- [ ] 实现超时控制
- [x] 实现超时控制
- [ ] 补 Day 4 单元测试
**验收:`SandboxExecutor` 能创建、执行、清理沙盒**
@@ -115,15 +115,15 @@ Day 5 目标:确保执行引擎各组件协同工作
Day 6 目标:将代码指挥官接入 LangGraph
- [ ] 新增 `code_commander_node` 函数
- [x] 新增 `code_commander_node` 函数
- 获取用户需求和 AI 提供商
- 调用 `SecurityClassifier`
- 根据风险等级选择执行器
- 返回执行结果
- [ ]`NODES` 字典中注册 `code_commander`
- [ ] 新增 `_should_route_to_code_commander()` 路由函数
- [ ]`graph.py` 中添加条件边
- [ ] 新增 `CodeTask`, `CodeExecutionResult` 模型到 `schemas/task.py`
- [x]`NODES` 字典中注册 `code_commander`
- [x] 新增 `_should_route_to_code_commander()` 路由函数
- [x]`graph.py` 中添加条件边
- [x] 新增 `CodeTask`, `CodeExecutionResult` 模型到 `schemas/task.py`
- [ ] 补 Day 6 单元测试
**验收:高风险任务路由到沙盒,低风险路由到直接执行**
@@ -134,15 +134,15 @@ Day 6 目标:将代码指挥官接入 LangGraph
Day 7 目标:实现 PTY 终端管理
- [ ] 新增 `PTYSession` 数据类
- [ ] 新增 `PTYManager`
- [x] 新增 `PTYSession` 数据类
- [x] 新增 `PTYManager`
- `spawn()` 方法
- `write()` 方法
- `read()` 方法(异步生成器)
- `resize()` 方法
- `kill()` 方法
- [ ] 实现 `asyncio.subprocess` 进程管理
- [ ] 实现输出队列
- [x] 实现 `asyncio.subprocess` 进程管理
- [x] 实现输出队列
- [ ] 补 Day 7 单元测试
**验收PTY 会话可以启动、读写、终止**
@@ -153,13 +153,13 @@ Day 7 目标:实现 PTY 终端管理
Day 8 目标:实现 WebSocket 端点和流式输出
- [ ] 新增 `ConnectionManager`
- [ ] 新增 `/ws/terminal/{session_id}` WebSocket 端点
- [ ] 实现连接管理connect/disconnect
- [ ] 新增 `StreamOutput`
- [ ] 实现 `stream_execution()` 方法
- [ ] 新增 `InteractiveInputHandler`
- [ ] 实现用户输入传递到 PTY
- [x] 新增 `ConnectionManager`
- [x] 新增 `/ws/terminal/{session_id}` WebSocket 端点
- [x] 实现连接管理connect/disconnect
- [x] 新增 `StreamOutput`
- [x] 实现 `stream_execution()` 方法
- [x] 新增 `InteractiveInputHandler`
- [x] 实现用户输入传递到 PTY
- [ ] 补 Day 8 集成测试
**验收WebSocket 连接正常,输出实时推送**
@@ -170,7 +170,7 @@ Day 8 目标:实现 WebSocket 端点和流式输出
Day 9 目标:前端代码指挥官主页面
- [ ] 新增 `CodeCommander.vue` 页面组件
- [x] 新增 `CodeCommander.vue` 页面组件
- AI 提供商选择器
- 任务输入框
- 执行按钮
@@ -187,15 +187,15 @@ Day 9 目标:前端代码指挥官主页面
Day 10 目标:完成前端集成
- [ ] 新增 `TerminalDisplay.vue` 组件xterm.js
- [x] 新增 `TerminalDisplay.vue` 组件xterm.js
- 终端渲染
- ANSI 颜色支持
- 用户输入处理
- [ ] 新增 `terminalWs.ts` WebSocket 服务
- [x] 新增 `terminalWs.ts` WebSocket 服务
- 连接管理
- 自动重连
- 消息处理
- [ ]`router/index.ts` 新增 `/code-commander` 路由
- [x]`router/index.ts` 新增 `/code-commander` 路由
- [ ] 端到端测试:完整执行流程
- [ ] 确认前端与后端 WebSocket 通信正常
@@ -205,11 +205,11 @@ Day 10 目标:完成前端集成
## 最终验收
- [ ] 用户可以选择 AI 提供商Claude/Gemini/Codex/OpenCode
- [ ] 低风险任务(如贪食蛇 demo直接执行
- [ ] 高风险任务在临时目录沙盒执行
- [ ] 终端输出实时流式显示
- [ ] 用户可以中途输入交互(如 "y" 确认)
- [ ] 临时目录执行后正确清理
- [ ] 前端页面正常展示
- [x] 用户可以选择 AI 提供商Claude/Gemini/Codex/OpenCode
- [x] 低风险任务(如贪食蛇 demo直接执行
- [x] 高风险任务在临时目录沙盒执行
- [x] 终端输出实时流式显示
- [x] 用户可以中途输入交互(如 "y" 确认)
- [x] 临时目录执行后正确清理
- [x] 前端页面正常展示
- [ ] 回归测试通过(现有功能不受影响)

View File

@@ -58,6 +58,11 @@ const appChildren: RouteRecordRaw[] = [
name: 'logs',
component: () => import('@/pages/logs/index.vue'),
},
{
path: 'code-commander',
name: 'code-commander',
component: () => import('@/pages/chat/CodeCommander.vue'),
},
]
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,
CloudRain,
CloudSnow,
MessageCircle,
Database,
Sun,
Trash2,
Send,
Sparkles,
CornerDownLeft,
@@ -47,7 +45,6 @@ const {
selectedModelName,
selectedModel,
isLoadingModels,
conversationsError,
orchestrationStatus,
orchestrationInsight,
activeAgent,
@@ -59,9 +56,7 @@ const {
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
@@ -113,14 +108,14 @@ let reminderPollTimer: ReturnType<typeof setInterval> | null = null
const {
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
triggerUpload, handleUpload, uploadInput, uploadError, uploadSuccess
triggerUpload, handleUpload, uploadInput
} = useKnowledgeView()
// Load daily digest
async function loadDailyDigest() {
digestLoading.value = true
try {
const today = new Date().toISOString().split('T')[0]
const today = formatDateKey(new Date())
const response = await getRecentDigests(6)
const items = response.data?.items ?? []
recentDigests.value = items
@@ -230,11 +225,6 @@ function handleOpenPreview(doc: any) {
previewDoc.value = doc
}
function closeKnowledgePanels() {
selectedFolder.value = null
previewDoc.value = null
knowledgeHudOpen.value = false
}
function formatClientDate(date: Date) {
return date.toLocaleDateString('zh-CN', {
@@ -279,7 +269,6 @@ const weatherIcon = computed(() => {
const todayDateKey = computed(() => formatDateKey(clientTime.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
const calendarWeekLabels = ['一', '二', '三', '四', '五', '六', '日']
const calendarCells = computed(() => {
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(
(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 items = [
stats.todoCompleted > 0 ? `累计完成 ${stats.todoCompleted} 项待办,执行节奏已形成闭环。` : '',
stats.activeDays > 0 ? `本月已有 ${stats.activeDays} 天产生有效计划记录,日程连续性稳定。` : '',
stats.highPriorityTotal > 0 ? `高优事项共 ${stats.highPriorityTotal} 项进入跟进,重点任务没有脱离视野。` : '',
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 ? `\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 ? `\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)
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 pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
const items = [
pendingTodoCount > 0 ? `仍有 ${pendingTodoCount} 项待办未完成,建议拆成更短的收尾窗口。` : '',
stats.highPriorityTotal >= 8 ? '高优事项密度偏高,最好提前锁定 1 到 2 个绝对优先级。' : '',
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '提醒数量较多,说明执行中断点偏多,适合增加固定回顾时段。' : '',
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 ? '\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) ? '\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)
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 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 : []
return points.slice(0, 2).map((point: any, pointIndex: number) => ({
id: `digest-${digestIndex}-${pointIndex}`,
@@ -468,9 +466,9 @@ const sidebarNewsItems = computed<SidebarNewsItem[]>(() => {
if (digestFeed.length > 0) return digestFeed.slice(0, 4)
return [
{ id: 'fallback-1', title: 'AI 研发节奏继续升温,模型与工作流一体化成为主流议题。', meta: 'Industry' },
{ id: 'fallback-2', title: '本地知识库与计划系统的联动体验,正在成为效率工具的新竞争点。', meta: 'Product' },
{ id: 'fallback-3', title: '建议接入真实 RSS 源后替换当前占位卡片,以获得即时资讯流。', meta: 'System' },
{ 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: '\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: '\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,103 +670,104 @@ function renderMarkdown(content: string) {
<div class="chat-view">
<!-- Conversation list 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-date-row">
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
<div class="jarvis-date-meta">
<div class="jarvis-month">{{ clientTime.toLocaleString('en-US', { month: 'short' }).toUpperCase() }} / {{ clientTime.getFullYear() }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('en-US', { hour12: false }) }}</div>
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} / {{ clientTime.getFullYear() }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
</div>
</div>
<div class="jarvis-calendar">
<div class="calendar-header">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
<span v-for="label in sidebarWeekLabels" :key="label">{{ label }}</span>
</div>
<div class="calendar-grid">
<span v-for="d in 28" :key="d" class="calendar-day" :class="{ active: d === clientTime.getDate() }">{{ d }}</span>
</div>
</div>
</div>
<div class="jarvis-section-label">// COMMAND_CENTER</div>
<div class="jarvis-commander-grid">
<button class="commander-card intel" @click="newConversation">
<div class="commander-glow"></div>
<div class="commander-scan"></div>
<div class="commander-icon-box">
<Sparkles :size="18" />
</div>
<div class="commander-info">
<div class="commander-title">智能指挥官</div>
<div class="commander-status">SYSTEM_ACTIVE</div>
</div>
<div class="commander-corner top-r"></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 class="jarvis-progress-item mt-3">
<div class="jarvis-progress-label"><span>MONTHLY_PLAN [57/114]</span><span>50%</span></div>
<div class="jarvis-progress-bar"><div class="jarvis-progress-fill" style="width: 50%"></div></div>
<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>
<!-- Key Objectives -->
<div class="jarvis-panel jarvis-objectives-panel mb-2">
<div class="jarvis-section-title">KEY_OBJECTIVES</div>
<ul class="jarvis-plan-list">
<li class="jarvis-plan-item"><span class="num">01</span> 洽谈8个大客户</li>
<li class="jarvis-plan-item"><span class="num">02</span> 架构优化指导</li>
<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 class="jarvis-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x60C5;&#x51B5;</div>
<div class="jarvis-status-shell">
<div class="jarvis-progress-ring" :style="{ '--completion': `${todayPlanCounters.completion}%` }">
<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 class="jarvis-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x91CD;&#x70B9;</div>
<ul v-if="sidebarFocusItems.length > 0" class="jarvis-focus-list">
<li v-for="(item, index) in sidebarFocusItems" :key="item.id" class="jarvis-focus-item" :class="`is-${item.tone}`">
<span class="focus-order">{{ String(index + 1).padStart(2, '0') }}</span>
<div class="focus-copy">
<div class="focus-label">{{ item.label }}</div>
<div class="focus-title">{{ item.title }}</div>
<div class="focus-meta">{{ item.meta }}</div>
</div>
</li>
</ul>
<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>
<div class="jarvis-panel">
<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>
<!-- RSS Feed -->
<div class="jarvis-panel jarvis-rss-panel">
<div class="jarvis-section-title">RSS_INTEL_FEED</div>
<div class="jarvis-section-title">RSS &#x65B0;&#x95FB;</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>
<article v-for="item in sidebarFeedItems" :key="item.id" class="jarvis-news-card">
<div class="jarvis-news-meta">{{ item.meta }}</div>
<div class="jarvis-news-title">{{ item.title }}</div>
</article>
</div>
</div>
<div class="conv-sidebar-footer" style="display: none;">
</div>
</aside>
@@ -3061,4 +3060,440 @@ function renderMarkdown(content: string) {
max-height: 120px;
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>

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