Compare commits

..

11 Commits

Author SHA1 Message Date
145c43f09c fix(backend): update conversation and schedule center schemas
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:51:11 +08:00
847d9f96db test(backend): add Hermes runtime and task router tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:47 +08:00
7f5b133fad feat(backend): add office router and agent runtime services
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:32 +08:00
21c869db62 feat(docs): add development documentation, prototypes, and war-room components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:41 +08:00
1ca8855751 chore(frontend): update styles, vite config, and package dependencies
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:08 +08:00
d8f8b0c177 feat(frontend): update schedule center and war room pages
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:53 +08:00
7e6eb6a7b3 feat(frontend): update chat page composables and sidebar plan implementation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:37 +08:00
c70e7e7253 feat(frontend): update API clients and Kanban components with enhanced UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:22 +08:00
39a9058de1 test(backend): update backend router tests for conversation, schedule center, and schema
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:07 +08:00
ac49c13965 feat(backend): update database schema and agent service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:53 +08:00
3e39b40a50 feat(backend): enhance task and schedule center APIs with expanded endpoints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:39 +08:00
1258 changed files with 17480 additions and 1013 deletions

View File

@@ -35,6 +35,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_task_columns(conn)
await ensure_log_columns(conn)
await ensure_message_columns(conn)
await ensure_conversation_columns(conn)
@@ -47,6 +48,195 @@ async def init_db():
await ensure_learning_artifact_tables(conn)
async def ensure_task_columns(conn):
rows = await _get_table_info(conn, 'tasks')
if not rows:
return
columns = {row[1] for row in rows}
required_columns = {
'source': "ALTER TABLE tasks ADD COLUMN source VARCHAR(32) DEFAULT 'manual' NOT NULL",
'conversation_id': "ALTER TABLE tasks ADD COLUMN conversation_id VARCHAR(36)",
'quadrant': "ALTER TABLE tasks ADD COLUMN quadrant VARCHAR(64)",
'assignee_type': "ALTER TABLE tasks ADD COLUMN assignee_type VARCHAR(32)",
'assignee_id': "ALTER TABLE tasks ADD COLUMN assignee_id VARCHAR(255)",
'dispatch_status': "ALTER TABLE tasks ADD COLUMN dispatch_status VARCHAR(32) DEFAULT 'idle' NOT NULL",
'dispatch_run_id': "ALTER TABLE tasks ADD COLUMN dispatch_run_id VARCHAR(64)",
'result_summary': "ALTER TABLE tasks ADD COLUMN result_summary TEXT",
'started_at': "ALTER TABLE tasks ADD COLUMN started_at DATETIME",
'last_synced_at': "ALTER TABLE tasks ADD COLUMN last_synced_at DATETIME",
}
for column, ddl in required_columns.items():
if column not in columns:
await conn.execute(text(ddl))
indexes = {
'ix_tasks_due_date': "CREATE INDEX IF NOT EXISTS ix_tasks_due_date ON tasks (due_date)",
'ix_tasks_source': "CREATE INDEX IF NOT EXISTS ix_tasks_source ON tasks (source)",
'ix_tasks_conversation_id': "CREATE INDEX IF NOT EXISTS ix_tasks_conversation_id ON tasks (conversation_id)",
'ix_tasks_quadrant': "CREATE INDEX IF NOT EXISTS ix_tasks_quadrant ON tasks (quadrant)",
'ix_tasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_type ON tasks (assignee_type)",
'ix_tasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_id ON tasks (assignee_id)",
'ix_tasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_status ON tasks (dispatch_status)",
'ix_tasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_run_id ON tasks (dispatch_run_id)",
}
for ddl in indexes.values():
await conn.execute(text(ddl))
history_rows = await _get_table_info(conn, 'task_histories')
if history_rows:
history_columns = {row[1] for row in history_rows}
if 'subtask_id' not in history_columns:
await conn.execute(text("ALTER TABLE task_histories ADD COLUMN subtask_id VARCHAR(36)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_histories_subtask_id ON task_histories (subtask_id)"))
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS task_subtasks (
id VARCHAR(36) PRIMARY KEY,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
task_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'todo',
order_index INTEGER NOT NULL DEFAULT 0,
assignee_type VARCHAR(32),
assignee_id VARCHAR(255),
dispatch_status VARCHAR(32) NOT NULL DEFAULT 'idle',
dispatch_run_id VARCHAR(64),
completed_at DATETIME,
FOREIGN KEY(task_id) REFERENCES tasks (id)
)
"""
)
)
subtask_rows = await _get_table_info(conn, 'task_subtasks')
subtask_columns = {row[1] for row in subtask_rows}
if 'result_summary' not in subtask_columns:
await conn.execute(text("ALTER TABLE task_subtasks ADD COLUMN result_summary TEXT"))
subtask_indexes = {
'ix_task_subtasks_task_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_task_id ON task_subtasks (task_id)",
'ix_task_subtasks_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_status ON task_subtasks (status)",
'ix_task_subtasks_order_index': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_order_index ON task_subtasks (order_index)",
'ix_task_subtasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_type ON task_subtasks (assignee_type)",
'ix_task_subtasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_id ON task_subtasks (assignee_id)",
'ix_task_subtasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_status ON task_subtasks (dispatch_status)",
'ix_task_subtasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_run_id ON task_subtasks (dispatch_run_id)",
}
for ddl in subtask_indexes.values():
await conn.execute(text(ddl))
# Normalize legacy/invalid enum-like values to prevent ORM Enum decoding failures.
await conn.execute(
text(
"""
UPDATE tasks
SET source = 'manual'
WHERE source IS NULL
OR TRIM(source) = ''
OR source NOT IN ('manual','chat','schedule_center','today_status','commander')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET status = 'todo'
WHERE status IS NULL
OR TRIM(status) = ''
OR status NOT IN ('todo','in_progress','done','cancelled')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET priority = 'medium'
WHERE priority IS NULL
OR TRIM(priority) = ''
OR priority NOT IN ('low','medium','high','urgent')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET quadrant = NULL
WHERE quadrant IS NOT NULL
AND (TRIM(quadrant) = '' OR quadrant NOT IN (
'urgent-important',
'not-urgent-important',
'urgent-not-important',
'not-urgent-not-important'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET assignee_type = NULL
WHERE assignee_type IS NOT NULL
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET dispatch_status = 'idle'
WHERE dispatch_status IS NULL
OR TRIM(dispatch_status) = ''
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET status = 'todo'
WHERE status IS NULL
OR TRIM(status) = ''
OR status NOT IN ('todo','in_progress','done','cancelled')
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET assignee_type = NULL
WHERE assignee_type IS NOT NULL
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET dispatch_status = 'idle'
WHERE dispatch_status IS NULL
OR TRIM(dispatch_status) = ''
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
"""
)
)
async def ensure_log_columns(conn):
result = await conn.execute(text("PRAGMA table_info(logs)"))
rows = result.fetchall()

View File

@@ -31,6 +31,7 @@ from app.routers import (
terminal_router,
tools_router,
remote_mount_router,
office_router,
)
from app.routers.scheduler import router as scheduler_router
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
@@ -133,6 +134,7 @@ app.include_router(agent_sessions_router)
app.include_router(terminal_router)
app.include_router(tools_router)
app.include_router(remote_mount_router)
app.include_router(office_router)
@app.get("/api/health")

View File

@@ -1,7 +1,8 @@
from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
URGENT = "urgent"
class TaskSource(str, PyEnum):
MANUAL = "manual"
CHAT = "chat"
SCHEDULE_CENTER = "schedule_center"
TODAY_STATUS = "today_status"
COMMANDER = "commander"
class TaskQuadrant(str, PyEnum):
URGENT_IMPORTANT = "urgent-important"
NOT_URGENT_IMPORTANT = "not-urgent-important"
URGENT_NOT_IMPORTANT = "urgent-not-important"
NOT_URGENT_NOT_IMPORTANT = "not-urgent-not-important"
class TaskAssigneeType(str, PyEnum):
USER = "user"
COMMANDER = "commander"
AGENT = "agent"
PLANNER = "planner"
EXECUTOR = "executor"
KNOWLEDGE = "knowledge"
ANALYST = "analyst"
CODER = "coder"
RESEARCHER = "researcher"
class TaskDispatchStatus(str, PyEnum):
IDLE = "idle"
QUEUED = "queued"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
DispatchStatus = TaskDispatchStatus
DispatchStatus = TaskDispatchStatus
class TaskHistoryAction(str, PyEnum):
CREATED = "created"
CREATED_FROM_CHAT = "created_from_chat"
UPDATED = "updated"
STATUS_CHANGED = "status_changed"
ASSIGNED = "assigned"
DELETED = "deleted"
SUBTASK_CREATED = "subtask_created"
SUBTASK_UPDATED = "subtask_updated"
SUBTASK_DELETED = "subtask_deleted"
SUBTASK_REORDERED = "subtask_reordered"
DISPATCHED_TO_COMMANDER = "dispatched_to_commander"
DISPATCH_STATUS_CHANGED = "dispatch_status_changed"
def enum_values(enum_cls: type[PyEnum]) -> list[str]:
return [item.value for item in enum_cls]
TASK_STATUS_ENUM = Enum(TaskStatus, values_callable=enum_values)
TASK_PRIORITY_ENUM = Enum(TaskPriority, values_callable=enum_values)
TASK_SOURCE_ENUM = Enum(TaskSource, values_callable=enum_values)
TASK_QUADRANT_ENUM = Enum(TaskQuadrant, values_callable=enum_values)
TASK_ASSIGNEE_TYPE_ENUM = Enum(TaskAssigneeType, values_callable=enum_values)
TASK_DISPATCH_STATUS_ENUM = Enum(TaskDispatchStatus, values_callable=enum_values)
class Task(BaseModel):
__tablename__ = "tasks"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
due_date = Column(DateTime, nullable=True)
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
due_date = Column(DateTime, nullable=True, index=True)
completed_at = Column(DateTime, nullable=True)
tags = Column(String(1000), nullable=True) # JSON 数组
tags = Column(String(1000), nullable=True) # JSON array
source = Column(TASK_SOURCE_ENUM, default=TaskSource.MANUAL, nullable=False, index=True)
conversation_id = Column(String(36), nullable=True, index=True)
quadrant = Column(TASK_QUADRANT_ENUM, nullable=True, index=True)
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
assignee_id = Column(String(255), nullable=True, index=True)
dispatch_status = Column(
TASK_DISPATCH_STATUS_ENUM,
default=TaskDispatchStatus.IDLE,
nullable=False,
index=True,
)
dispatch_run_id = Column(String(64), nullable=True, index=True)
result_summary = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=True)
last_synced_at = Column(DateTime, nullable=True)
history = relationship("TaskHistory", back_populates="task", cascade="all, delete-orphan")
subtasks = relationship(
"TaskSubTask",
back_populates="task",
cascade="all, delete-orphan",
order_by="TaskSubTask.order_index.asc()",
)
history = relationship(
"TaskHistory",
back_populates="task",
cascade="all, delete-orphan",
order_by="TaskHistory.created_at.desc()",
)
class TaskSubTask(BaseModel):
__tablename__ = "task_subtasks"
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
order_index = Column(Integer, default=0, nullable=False, index=True)
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
assignee_id = Column(String(255), nullable=True, index=True)
dispatch_status = Column(
TASK_DISPATCH_STATUS_ENUM,
default=TaskDispatchStatus.IDLE,
nullable=False,
index=True,
)
dispatch_run_id = Column(String(64), nullable=True, index=True)
result_summary = Column(Text, nullable=True)
completed_at = Column(DateTime, nullable=True)
task = relationship("Task", back_populates="subtasks")
class TaskHistory(BaseModel):
__tablename__ = "task_histories"
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
action = Column(String(100), nullable=False) # created, status_changed, updated, deleted
subtask_id = Column(String(36), ForeignKey("task_subtasks.id"), nullable=True, index=True)
action = Column(String(100), nullable=False)
old_value = Column(Text, nullable=True)
new_value = Column(Text, nullable=True)

View File

@@ -23,3 +23,4 @@ from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router
from app.routers.tools import router as tools_router
from app.routers.remote_mount import router as remote_mount_router
from app.routers.office import router as office_router

View File

@@ -100,6 +100,7 @@ async def chat(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -115,7 +116,7 @@ async def chat(
conversation_id=conv_id,
message_id=msg_id,
content=content,
agent_name="jarvis",
agent_name=data.runtime or "jarvis",
model_name=model_name,
)
@@ -141,6 +142,7 @@ async def chat_stream(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
except ValueError as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"

View File

@@ -0,0 +1,179 @@
"""Office Status API - Star Office style visualization for Jarvis agents."""
from datetime import datetime, timedelta
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/office", tags=["office"])
# ============================================================================
# State Definitions (mapped to spaceship areas)
# ============================================================================
# idle → Rest Bay (breakroom)
# writing/researching/executing → Command Console (writing)
# syncing → Server Room (syncing)
# error → Repair Bay (error)
SHIP_AREAS = {
"breakroom": {"x": 200, "y": 300}, # Rest Bay - bottom left
"writing": {"x": 640, "y": 200}, # Command Console - center top
"server": {"x": 640, "y": 400}, # Server Room - center bottom
"error": {"x": 1000, "y": 300}, # Repair Bay - right side
}
STATES = {
"idle": {"name": "待命", "area": "breakroom"},
"writing": {"name": "执行中", "area": "writing"},
"researching": {"name": "研究中", "area": "writing"},
"executing": {"name": "执行中", "area": "writing"},
"syncing": {"name": "同步中", "area": "server"},
"error": {"name": "故障中", "area": "error"},
}
# ============================================================================
# Data Models
# ============================================================================
class AgentState(BaseModel):
agent_id: str
name: str
state: Literal["idle", "writing", "researching", "executing", "syncing", "error"]
detail: str | None = None
area: str | None = None
is_main: bool = False
auth_status: str = "approved" # approved, pending, rejected, offline
class SetStateRequest(BaseModel):
state: str
detail: str | None = None
class OfficeStatus(BaseModel):
state: str
detail: str | None = None
agent_name: str
timestamp: str
class OfficeMemo(BaseModel):
success: bool
date: str
memo: str
# ============================================================================
# In-Memory State (in production, this would come from Jarvis's agent state)
# ============================================================================
_current_state: dict = {
"agent_id": "jarvis-main",
"name": "JARVIS",
"state": "idle",
"detail": "战舰启动中...",
"area": "breakroom",
"is_main": True,
"auth_status": "approved",
}
def normalize_state(state: str | None) -> str:
"""Normalize various state names to our canonical states."""
if not state:
return "idle"
state = state.lower().strip()
if state in ("working", "run", "running"):
return "writing"
if state in ("sync", "syncing"):
return "syncing"
if state in ("research", "researching"):
return "researching"
if state in ("execute", "executing"):
return "executing"
if state == "error":
return "error"
return "idle"
def get_state_info(state: str) -> dict:
"""Get state info including area mapping."""
return STATES.get(state, STATES["idle"])
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/status", response_model=OfficeStatus)
async def get_status():
"""Get current agent status."""
state_info = get_state_info(_current_state["state"])
return OfficeStatus(
state=_current_state["state"],
detail=_current_state.get("detail"),
agent_name=_current_state["name"],
timestamp=datetime.now().isoformat(),
)
@router.get("/yesterday-memo", response_model=OfficeMemo)
async def get_yesterday_memo():
"""Return a lightweight public memo for the Star Office viewer."""
target_date = (datetime.now() - timedelta(days=1)).date().isoformat()
detail = (_current_state.get("detail") or "No detailed log was recorded.").strip()
memo = (
"Yesterday summary\n"
f"- Last known state: {_current_state['state']}\n"
f"- Detail: {detail}\n"
"- Next step: open the command surface and continue from the current work thread."
)
return OfficeMemo(success=True, date=target_date, memo=memo)
@router.post("/set_state")
async def set_state(req: SetStateRequest):
"""Set the current agent state."""
normalized = normalize_state(req.state)
state_info = get_state_info(normalized)
_current_state["state"] = normalized
_current_state["detail"] = req.detail or ""
_current_state["area"] = state_info["area"]
return {
"success": True,
"state": normalized,
"area": state_info["area"],
"detail": _current_state["detail"],
}
@router.get("/agents")
async def get_agents():
"""Get all agents in the office (for multi-agent support)."""
# For now, return just the main agent
# In full implementation, this would query Jarvis's agent registry
state_info = get_state_info(_current_state["state"])
return [
{
"agentId": _current_state["agent_id"],
"name": _current_state["name"],
"state": _current_state["state"],
"detail": _current_state.get("detail", ""),
"area": state_info["area"],
"isMain": _current_state.get("is_main", True),
"authStatus": _current_state.get("auth_status", "approved"),
"updated_at": datetime.now().isoformat(),
}
]
@router.get("/areas")
async def get_areas():
"""Get all spaceship areas with coordinates."""
return SHIP_AREAS
@router.get("/health")
async def health():
"""Health check."""
return {"status": "ok", "service": "office"}

View File

@@ -1,25 +1,62 @@
from calendar import monthrange
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority
from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
from app.models.todo import DailyTodo
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.schedule_center import (
ScheduleCenterCommanderSummaryOut,
ScheduleCenterDateOut,
ScheduleCenterDaySummary,
ScheduleCenterFocusTaskOut,
ScheduleCenterMonthOut,
ScheduleCenterQuadrantOut,
ScheduleCenterQuadrantTaskOut,
)
from app.schemas.task import build_task_out
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = {
TaskQuadrant.URGENT_IMPORTANT: {
"title": "重要且紧急",
"subtitle": "CRITICAL",
"color": "#ff4757",
"glow_color": "rgba(255, 71, 87, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_IMPORTANT: {
"title": "重要不紧急",
"subtitle": "PLANNED",
"color": "#ffd93d",
"glow_color": "rgba(255, 217, 61, 0.4)",
"icon": "",
},
TaskQuadrant.URGENT_NOT_IMPORTANT: {
"title": "紧急不重要",
"subtitle": "DELEGATE",
"color": "#00d4ff",
"glow_color": "rgba(0, 212, 255, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: {
"title": "不重要不紧急",
"subtitle": "ELIMINATE",
"color": "#6bcf7f",
"glow_color": "rgba(107, 207, 127, 0.4)",
"icon": "",
},
}
def _build_summary(
target_date: str,
@@ -39,6 +76,146 @@ def _build_summary(
)
def _coerce_enum(value, enum_cls, default=None):
if value is None:
return default
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return default
for item in enum_cls:
if raw == item.value or raw.lower() == item.value:
return item
if raw.upper() == item.name:
return item
return default
def _derive_quadrant(task: Task) -> TaskQuadrant:
quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None)
if quadrant is not None:
return quadrant
priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM)
status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO)
if priority in {TaskPriority.HIGH, TaskPriority.URGENT}:
return TaskQuadrant.URGENT_IMPORTANT
if status == TaskStatus.IN_PROGRESS:
return TaskQuadrant.NOT_URGENT_IMPORTANT
if priority == TaskPriority.MEDIUM:
return TaskQuadrant.URGENT_NOT_IMPORTANT
return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT
def _enum_value(value) -> str | None:
if value is None:
return None
if hasattr(value, "value"):
return str(value.value)
if isinstance(value, str):
raw = value.strip()
return raw or None
return str(value)
def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]:
priority_rank = {
TaskPriority.URGENT: 0,
TaskPriority.HIGH: 1,
TaskPriority.MEDIUM: 2,
TaskPriority.LOW: 3,
}
status_rank = {
TaskStatus.IN_PROGRESS: 0,
TaskStatus.TODO: 1,
TaskStatus.DONE: 2,
TaskStatus.CANCELLED: 3,
}
ordered = sorted(
tasks,
key=lambda item: (
status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99),
priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99),
item.created_at,
),
)
return [
ScheduleCenterFocusTaskOut(
id=item.id,
title=item.title,
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM),
quadrant=_derive_quadrant(item),
assignee_type=_enum_value(item.assignee_type),
assignee_id=item.assignee_id,
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
due_date=item.due_date,
)
for item in ordered[:6]
]
def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]:
buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = {
quadrant: [] for quadrant in QUADRANT_META
}
for task in tasks:
quadrant = _derive_quadrant(task)
buckets[quadrant].append(
ScheduleCenterQuadrantTaskOut(
id=task.id,
title=task.title,
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
assignee_type=_enum_value(task.assignee_type),
assignee_id=task.assignee_id,
)
)
return [
ScheduleCenterQuadrantOut(
id=quadrant,
title=meta["title"],
subtitle=meta["subtitle"],
color=meta["color"],
glow_color=meta["glow_color"],
icon=meta["icon"],
tasks=buckets[quadrant],
)
for quadrant, meta in QUADRANT_META.items()
]
def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut:
counts = ScheduleCenterCommanderSummaryOut()
for task in tasks:
states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)]
for state in states:
normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE)
if normalized == TaskDispatchStatus.IDLE:
continue
counts.total += 1
if normalized == TaskDispatchStatus.QUEUED:
counts.queued += 1
elif normalized == TaskDispatchStatus.RUNNING:
counts.running += 1
elif normalized == TaskDispatchStatus.COMPLETED:
counts.completed += 1
elif normalized == TaskDispatchStatus.FAILED:
counts.failed += 1
if counts.running > 0:
counts.overall_status = "running"
elif counts.queued > 0:
counts.overall_status = "queued"
elif counts.failed > 0 and counts.completed == 0:
counts.overall_status = "failed"
return counts
@router.get("/month", response_model=ScheduleCenterMonthOut)
async def get_month_schedule(
year: int = Query(..., ge=2000, le=2100),
@@ -53,27 +230,43 @@ async def get_month_schedule(
start_dt = datetime.combine(month_start, datetime.min.time())
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
todos = (await db.execute(
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
)).scalars().all()
tasks = (await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
todos = (
await db.execute(
select(DailyTodo).where(
DailyTodo.user_id == current_user.id,
DailyTodo.todo_date >= start_key,
DailyTodo.todo_date <= end_key,
)
)
)).scalars().all()
reminders = (await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
).scalars().all()
tasks = (
await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
)
)).scalars().all()
goals = (await db.execute(
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
)).scalars().all()
).scalars().all()
reminders = (
await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
)
).scalars().all()
goals = (
await db.execute(
select(Goal).where(
Goal.user_id == current_user.id,
Goal.goal_date >= start_key,
Goal.goal_date <= end_key,
)
)
).scalars().all()
todo_map: dict[str, list[DailyTodo]] = {}
for item in todos:
@@ -96,18 +289,20 @@ async def get_month_schedule(
days = []
for day in range(1, days_in_month + 1):
date_key = month_start.replace(day=day).isoformat()
days.append(_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
))
days.append(
_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
)
)
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
@router.get("/date", response_model=ScheduleCenterDateOut)
@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True)
async def get_date_schedule(
date_str: date = Query(...),
current_user: User = Depends(get_current_user),
@@ -118,43 +313,55 @@ async def get_date_schedule(
end_dt = datetime.combine(target_date, datetime.max.time())
date_key = target_date.isoformat()
todos = (await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)).scalars().all()
tasks = (await db.execute(
select(Task)
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
todos = (
await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)
.order_by(Task.created_at.desc())
)).scalars().all()
reminders = (await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
).scalars().all()
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
.order_by(Task.priority.desc(), Task.created_at.desc())
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)).scalars().all()
goals = (await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)).scalars().all()
).scalars().unique().all()
reminders = (
await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)
).scalars().all()
goals = (
await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)
).scalars().all()
summary = _build_summary(date_key, todos, tasks, reminders, goals)
return ScheduleCenterDateOut(
date=date_key,
todos=todos,
tasks=tasks,
tasks=[build_task_out(task) for task in tasks],
reminders=reminders,
goals=goals,
summary=summary,
focus_tasks=_build_focus_tasks(tasks),
quadrants=_build_quadrants(tasks),
commander_summary=_build_commander_summary(tasks),
generated_at=datetime.now(UTC),
)

View File

@@ -1,15 +1,116 @@
import json
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.task import Task, TaskStatus
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.task import TaskCreate, TaskUpdate, TaskOut
from app.schemas.task import (
TaskCreate,
TaskDetailOut,
TaskDispatchRequest,
TaskDispatchResponse,
TaskHistoryOut,
TaskOut,
TaskSubTaskCreate,
TaskSubTaskOut,
TaskSubTaskReorderRequest,
TaskSubTaskUpdate,
TaskUpdate,
build_task_detail_out,
)
from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch
router = APIRouter(prefix="/api/tasks", tags=["看板"])
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
def _encode_tags(tags: list[str] | None) -> str | None:
if not tags:
return None
return json.dumps(tags, ensure_ascii=False)
def _decode_tags(value: str | None) -> list[str]:
if not value:
return []
try:
payload = json.loads(value)
except json.JSONDecodeError:
return [value]
if isinstance(payload, list):
return [str(item) for item in payload]
return [str(payload)]
def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut:
return TaskSubTaskOut.model_validate(subtask)
def _history_to_out(history) -> TaskHistoryOut:
return TaskHistoryOut.model_validate(history)
def _task_to_out(task: Task) -> TaskOut:
return TaskOut(
id=task.id,
title=task.title,
description=task.description,
status=task.status,
priority=task.priority,
due_date=task.due_date,
completed_at=task.completed_at,
tags=_decode_tags(task.tags),
source=task.source or TaskSource.MANUAL,
conversation_id=task.conversation_id,
quadrant=task.quadrant,
assignee_type=task.assignee_type,
assignee_id=task.assignee_id,
dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE,
dispatch_run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
created_at=task.created_at,
updated_at=task.updated_at,
)
def _task_detail_to_out(task: Task) -> TaskDetailOut:
return build_task_detail_out(task)
async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task:
task = await load_task_with_details(db, task_id=task_id, user_id=user_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
def _sync_task_completion(task: Task) -> None:
if task.status == TaskStatus.DONE:
task.completed_at = task.completed_at or datetime.now(UTC)
elif task.status != TaskStatus.CANCELLED:
task.completed_at = None
def _sync_subtask_completion(subtask: TaskSubTask) -> None:
if subtask.status == TaskStatus.DONE:
subtask.completed_at = subtask.completed_at or datetime.now(UTC)
elif subtask.status != TaskStatus.CANCELLED:
subtask.completed_at = None
@router.get("", response_model=list[TaskOut])
@@ -18,12 +119,28 @@ async def list_tasks(
due_date: date | None = Query(default=None),
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
quadrant: TaskQuadrant | None = None,
assignee_type: TaskAssigneeType | None = None,
dispatch_status: TaskDispatchStatus | None = None,
conversation_id: str | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Task).where(Task.user_id == current_user.id)
query = (
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(Task.user_id == current_user.id)
)
if status:
query = query.where(Task.status == status)
if quadrant:
query = query.where(Task.quadrant == quadrant)
if assignee_type:
query = query.where(Task.assignee_type == assignee_type)
if dispatch_status:
query = query.where(Task.dispatch_status == dispatch_status)
if conversation_id:
query = query.where(Task.conversation_id == conversation_id)
if due_date:
start = datetime.combine(due_date, datetime.min.time())
end = datetime.combine(due_date, datetime.max.time())
@@ -32,65 +149,109 @@ async def list_tasks(
start = datetime.combine(date_from, datetime.min.time()) if date_from else None
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
if start and end and start > end:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
raise HTTPException(status_code=400, detail="date_from cannot be later than date_to")
if start is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
if end is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
query = query.order_by(desc(Task.created_at))
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
result = await db.execute(query)
return result.scalars().all()
tasks = result.scalars().unique().all()
return [_task_to_out(task) for task in tasks]
@router.post("", response_model=TaskOut, status_code=201)
@router.post("", response_model=TaskDetailOut, status_code=201)
async def create_task(
data: TaskCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
import json
task = Task(
user_id=current_user.id,
title=data.title,
description=data.description,
priority=data.priority,
due_date=data.due_date,
tags=json.dumps(data.tags) if data.tags else None,
tags=_encode_tags(data.tags),
source=data.source,
conversation_id=data.conversation_id,
quadrant=data.quadrant,
assignee_type=data.assignee_type,
assignee_id=data.assignee_id,
status=data.status,
)
_sync_task_completion(task)
if data.source == TaskSource.CHAT:
append_task_history(task, action="created_from_chat", new_value=task.title)
append_task_history(task, action="created", new_value=task.title)
for index, subtask_data in enumerate(data.subtasks):
subtask = TaskSubTask(
title=subtask_data.title,
description=subtask_data.description,
status=subtask_data.status,
order_index=index if subtask_data.order_index is None else subtask_data.order_index,
assignee_type=subtask_data.assignee_type,
assignee_id=subtask_data.assignee_id,
)
_sync_subtask_completion(subtask)
task.subtasks.append(subtask)
append_task_history(task, action="subtask_created", new_value=subtask.title)
db.add(task)
await db.commit()
await db.refresh(task)
return task
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
if data.dispatch_to_commander:
await queue_task_dispatch(task, db=db)
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.patch("/{task_id}", response_model=TaskOut)
@router.get("/{task_id}", response_model=TaskDetailOut)
async def get_task(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.patch("/{task_id}", response_model=TaskDetailOut)
async def update_task(
task_id: str,
data: TaskUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
import json
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
payload = data.model_dump(exclude_none=True)
previous_assignee = (task.assignee_type, task.assignee_id)
for field, value in data.model_dump(exclude_none=True).items():
for field, value in payload.items():
previous = getattr(task, field)
if field == "tags":
setattr(task, field, json.dumps(value))
elif field == "status" and value == TaskStatus.DONE:
task.completed_at = datetime.now(UTC)
setattr(task, field, value)
elif field == "status":
task.completed_at = None
setattr(task, field, value)
task.tags = _encode_tags(value)
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
continue
setattr(task, field, value)
if field == "status":
_sync_task_completion(task)
append_task_history(task, action="status_changed", old_value=previous, new_value=value)
elif previous != value:
append_task_history(task, action="updated", old_value=previous, new_value=value)
if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id):
append_task_history(
task,
action="assigned",
old_value=f"{previous_assignee[0]}:{previous_assignee[1]}",
new_value=f"{task.assignee_type}:{task.assignee_id}",
)
await db.commit()
await db.refresh(task)
return task
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.delete("/{task_id}", status_code=204)
@@ -99,11 +260,171 @@ async def delete_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
await db.delete(task)
await db.commit()
@router.post("/{task_id}/subtasks", status_code=201)
async def create_subtask(
task_id: str,
data: TaskSubTaskCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
max_order = max((item.order_index for item in task.subtasks), default=-1)
subtask = TaskSubTask(
task_id=task.id,
title=data.title,
description=data.description,
status=data.status,
order_index=max_order + 1 if data.order_index is None else data.order_index,
assignee_type=data.assignee_type,
assignee_id=data.assignee_id,
)
_sync_subtask_completion(subtask)
task.subtasks.append(subtask)
append_task_history(task, action="subtask_created", new_value=data.title)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
detail = _task_detail_to_out(task)
created_subtask = max(
(item for item in detail.subtasks if item.title == data.title),
key=lambda item: (item.order_index, item.created_at),
default=None,
)
if created_subtask is None:
raise HTTPException(status_code=500, detail="Created subtask could not be loaded")
return {
**created_subtask.model_dump(),
"task": detail.model_dump(),
"subtasks": [item.model_dump() for item in detail.subtasks],
"history": [item.model_dump() for item in detail.history],
"dispatch": detail.dispatch.model_dump(),
"dispatch_summary": detail.dispatch_summary.model_dump(),
}
@router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
async def update_subtask(
task_id: str,
subtask_id: str,
data: TaskSubTaskUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
payload = data.model_dump(exclude_none=True)
for field, value in payload.items():
previous = getattr(subtask, field)
setattr(subtask, field, value)
if field == "status":
_sync_subtask_completion(subtask)
if previous != value:
append_task_history(
task,
action="updated" if field != "status" else "status_changed",
old_value=f"{subtask.id}:{field}:{previous}",
new_value=f"{subtask.id}:{field}:{value}",
)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
async def delete_subtask(
task_id: str,
subtask_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title)
await db.delete(subtask)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut)
async def reorder_subtasks(
task_id: str,
data: TaskSubTaskReorderRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
lookup = {item.id: item for item in task.subtasks}
for item in data.items:
subtask = lookup.get(item.id)
if subtask is None:
raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}")
subtask.order_index = item.order_index
append_task_history(
task,
action="subtask_reordered",
new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items),
)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse)
async def dispatch_task(
task_id: str,
data: TaskDispatchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.target != "commander":
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
_, payload = await queue_task_dispatch(task, db=db)
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return TaskDispatchResponse(
status=task.dispatch_status,
run_id=task.dispatch_run_id,
task=_task_detail_to_out(task),
payload=payload,
)
@router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse)
async def dispatch_subtask(
task_id: str,
subtask_id: str,
data: TaskDispatchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.target != "commander":
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
_, payload = await queue_task_dispatch(task, db=db, subtask=subtask)
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return TaskDispatchResponse(
status=subtask.dispatch_status,
run_id=subtask.dispatch_run_id,
task=_task_detail_to_out(task),
payload=payload,
)

View File

@@ -1,5 +1,7 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
class MessageCreate(BaseModel):
@@ -37,6 +39,7 @@ class ChatRequest(BaseModel):
conversation_id: str | None = None
agent_id: str | None = None
model_name: str | None = None
runtime: Literal["jarvis", "hermes"] | None = None
file_ids: list[str] = []

View File

@@ -1,7 +1,8 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.models.task import TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
from app.schemas.goal import GoalOut
from app.schemas.reminder import ReminderOut
from app.schemas.task import TaskOut
@@ -18,6 +19,47 @@ class ScheduleCenterDaySummary(BaseModel):
goal_total: int
class ScheduleCenterFocusTaskOut(BaseModel):
id: str
title: str
status: TaskStatus
priority: TaskPriority
quadrant: TaskQuadrant | None = None
assignee_type: str | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus
due_date: datetime | None = None
class ScheduleCenterQuadrantTaskOut(BaseModel):
id: str
title: str
status: TaskStatus
priority: TaskPriority
dispatch_status: TaskDispatchStatus
assignee_type: str | None = None
assignee_id: str | None = None
class ScheduleCenterQuadrantOut(BaseModel):
id: TaskQuadrant
title: str
subtitle: str
color: str
glow_color: str
icon: str
tasks: list[ScheduleCenterQuadrantTaskOut] = Field(default_factory=list)
class ScheduleCenterCommanderSummaryOut(BaseModel):
total: int = 0
queued: int = 0
running: int = 0
completed: int = 0
failed: int = 0
overall_status: str | None = None
class ScheduleCenterMonthOut(BaseModel):
month: str
days: list[ScheduleCenterDaySummary]
@@ -30,4 +72,9 @@ class ScheduleCenterDateOut(BaseModel):
reminders: list[ReminderOut]
goals: list[GoalOut]
summary: ScheduleCenterDaySummary
focus_tasks: list[ScheduleCenterFocusTaskOut] = Field(default_factory=list)
quadrants: list[ScheduleCenterQuadrantOut] = Field(default_factory=list)
commander_summary: ScheduleCenterCommanderSummaryOut = Field(
default_factory=ScheduleCenterCommanderSummaryOut,
)
generated_at: datetime

View File

@@ -1,14 +1,146 @@
from pydantic import BaseModel
from __future__ import annotations
import json
from datetime import datetime
from app.models.task import TaskStatus, TaskPriority
from pydantic import BaseModel, Field
from sqlalchemy import inspect
from sqlalchemy.orm.attributes import NO_VALUE
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
def _coerce_enum(value, enum_cls, default=None):
if value is None:
return default
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return default
for item in enum_cls:
if raw == item.value or raw.lower() == item.value:
return item
if raw.upper() == item.name:
return item
return default
def parse_tags(raw_tags: str | None) -> list[str]:
if not raw_tags:
return []
try:
parsed = json.loads(raw_tags)
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
return [str(item) for item in parsed]
def serialize_tags(tags: list[str] | None) -> str | None:
if not tags:
return None
return json.dumps([str(item) for item in tags], ensure_ascii=False)
class TaskSubTaskCreate(BaseModel):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.TODO
order_index: int | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
class TaskSubTaskUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
order_index: int | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus | None = None
dispatch_run_id: str | None = None
result_summary: str | None = None
class TaskSubTaskReorderItem(BaseModel):
id: str
order_index: int
class TaskSubTaskReorderRequest(BaseModel):
items: list[TaskSubTaskReorderItem] = Field(default_factory=list)
class TaskSubTaskOut(BaseModel):
id: str
task_id: str
title: str
description: str | None
status: TaskStatus
order_index: int
assignee_type: TaskAssigneeType | None
assignee_id: str | None
dispatch_status: TaskDispatchStatus
dispatch_run_id: str | None
result_summary: str | None = None
completed_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskHistoryOut(BaseModel):
id: str
task_id: str
action: str
old_value: str | None
new_value: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskDispatchSummary(BaseModel):
status: TaskDispatchStatus
run_id: str | None = None
result_summary: str | None = None
started_at: datetime | None = None
last_synced_at: datetime | None = None
total_subtasks: int = 0
dispatched_subtasks: int = 0
subtask_dispatch_statuses: dict[str, int] = Field(default_factory=dict)
class TaskCreate(BaseModel):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.TODO
priority: TaskPriority = TaskPriority.MEDIUM
due_date: datetime | None = None
tags: list[str] | None = None
source: TaskSource = TaskSource.MANUAL
conversation_id: str | None = None
quadrant: TaskQuadrant | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
subtasks: list[TaskSubTaskCreate] = Field(default_factory=list)
dispatch_to_commander: bool = False
class TaskUpdate(BaseModel):
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
priority: TaskPriority | None = None
due_date: datetime | None = None
tags: list[str] | None = None
source: TaskSource | None = None
conversation_id: str | None = None
quadrant: TaskQuadrant | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus | None = None
dispatch_run_id: str | None = None
result_summary: str | None = None
started_at: datetime | None = None
last_synced_at: datetime | None = None
class TaskOut(BaseModel):
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
priority: TaskPriority
due_date: datetime | None
completed_at: datetime | None
tags: str | None
tags: list[str] = Field(default_factory=list)
source: TaskSource
conversation_id: str | None
quadrant: TaskQuadrant | None
assignee_type: TaskAssigneeType | None
assignee_id: str | None
dispatch_status: TaskDispatchStatus
dispatch_run_id: str | None
result_summary: str | None
started_at: datetime | None
last_synced_at: datetime | None
subtask_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskDetailOut(TaskOut):
subtasks: list[TaskSubTaskOut] = Field(default_factory=list)
history: list[TaskHistoryOut] = Field(default_factory=list)
dispatch: TaskDispatchSummary
dispatch_summary: TaskDispatchSummary
class TaskDispatchRequest(BaseModel):
target: str = "commander"
conversation_id: str | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
class TaskDispatchResponse(BaseModel):
status: TaskDispatchStatus
run_id: str | None = None
task: TaskDetailOut
payload: dict[str, object] = Field(default_factory=dict)
class DailyPlanRequest(BaseModel):
user_id: str
def build_task_out(task: Task) -> TaskOut:
subtasks_attr = inspect(task).attrs.subtasks.loaded_value
return TaskOut(
id=task.id,
title=task.title,
description=task.description,
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
due_date=task.due_date,
completed_at=task.completed_at,
tags=parse_tags(task.tags),
source=_coerce_enum(task.source, TaskSource, TaskSource.MANUAL),
conversation_id=task.conversation_id,
quadrant=_coerce_enum(task.quadrant, TaskQuadrant, None),
assignee_type=_coerce_enum(task.assignee_type, TaskAssigneeType, None),
assignee_id=task.assignee_id,
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
dispatch_run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
subtask_count=0 if subtasks_attr is NO_VALUE else len(subtasks_attr or []),
created_at=task.created_at,
updated_at=task.updated_at,
)
def build_task_detail_out(task: Task) -> TaskDetailOut:
normalized_task_dispatch = _coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE)
normalized_subtasks = [
TaskSubTaskOut(
id=item.id,
task_id=item.task_id,
title=item.title,
description=item.description,
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
order_index=item.order_index,
assignee_type=_coerce_enum(item.assignee_type, TaskAssigneeType, None),
assignee_id=item.assignee_id,
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
dispatch_run_id=item.dispatch_run_id,
result_summary=item.result_summary,
completed_at=item.completed_at,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in task.subtasks
]
subtask_dispatch_statuses: dict[str, int] = {}
for item in normalized_subtasks:
key = item.dispatch_status.value
subtask_dispatch_statuses[key] = subtask_dispatch_statuses.get(key, 0) + 1
dispatched_subtasks = sum(1 for item in normalized_subtasks if item.dispatch_status != TaskDispatchStatus.IDLE)
return TaskDetailOut(
**build_task_out(task).model_dump(),
subtasks=normalized_subtasks,
history=[TaskHistoryOut.model_validate(item) for item in task.history],
dispatch=TaskDispatchSummary(
status=normalized_task_dispatch,
run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
total_subtasks=len(normalized_subtasks),
dispatched_subtasks=dispatched_subtasks,
subtask_dispatch_statuses=subtask_dispatch_statuses,
),
dispatch_summary=TaskDispatchSummary(
status=normalized_task_dispatch,
run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
total_subtasks=len(normalized_subtasks),
dispatched_subtasks=dispatched_subtasks,
subtask_dispatch_statuses=subtask_dispatch_statuses,
),
)

View File

@@ -0,0 +1,9 @@
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter, hermes_runtime_adapter
from app.services.agent_runtime.jarvis_runtime import JarvisRuntimeAdapter, jarvis_runtime_adapter
__all__ = [
"HermesRuntimeAdapter",
"hermes_runtime_adapter",
"JarvisRuntimeAdapter",
"jarvis_runtime_adapter",
]

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Protocol
from app.models.conversation import Conversation, Message
from app.models.user import User
RuntimeName = str
@dataclass(slots=True)
class RuntimePreparedContext:
user: User
conversation: Conversation
user_message: Message
assistant_message: Message
raw_message: str
full_message: str
file_ids: list[str]
model_name: str | None
memory_context: str | None
class ChatRuntime(Protocol):
name: RuntimeName
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]: ...
async def chat_once(
self,
prepared: RuntimePreparedContext,
) -> tuple[str, str | None]: ...

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import asyncio
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, AsyncGenerator
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
class HermesRuntimeAdapter(ChatRuntime):
name = "hermes"
def __init__(self) -> None:
self._repo_path = Path(__file__).resolve().parents[4] / ".tmp" / "hermes-agent"
self._agent_class = None
def probe(self) -> dict[str, Any]:
cli_path = self._repo_path / "cli.py"
run_agent_path = self._repo_path / "run_agent.py"
return {
"repo_path": str(self._repo_path),
"repo_exists": self._repo_path.exists(),
"cli_exists": cli_path.exists(),
"run_agent_exists": run_agent_path.exists(),
"supports_single_query": True,
"supports_resume": True,
"integration_mode": "python_ai_agent_bridge",
}
def _load_agent_class(self):
if self._agent_class is not None:
return self._agent_class
run_agent_path = self._repo_path / "run_agent.py"
if not run_agent_path.exists():
raise RuntimeError(f"Hermes run_agent.py 未找到: {run_agent_path}")
repo_path = str(self._repo_path)
if repo_path not in sys.path:
sys.path.insert(0, repo_path)
spec = importlib.util.spec_from_file_location("jarvis_hermes_run_agent", run_agent_path)
if spec is None or spec.loader is None:
raise RuntimeError("无法加载 Hermes run_agent 模块")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self._agent_class = getattr(module, "AIAgent")
return self._agent_class
def _build_agent(self, prepared: RuntimePreparedContext, session_id: str):
agent_class = self._load_agent_class()
kwargs: dict[str, Any] = {
"session_id": session_id,
"platform": "jarvis",
"user_id": prepared.user.id,
"quiet_mode": True,
"persist_session": True,
"skip_context_files": True,
"max_iterations": 30,
}
if prepared.model_name:
kwargs["model"] = prepared.model_name
return agent_class(**kwargs)
def _build_system_message(self, prepared: RuntimePreparedContext) -> str:
parts = [
"You are Hermes running inside the Jarvis chat runtime.",
"Return normal assistant text for the user. Do not mention internal bridge details unless asked.",
]
if prepared.memory_context:
parts.append(prepared.memory_context)
return "\n\n".join(parts)
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]:
handle = hermes_session_manager.get_or_create(
conversation_id=prepared.conversation.id,
user_id=prepared.user.id,
)
async with handle.lock:
yield {
"type": "progress",
"stage": "planning",
"label": "Hermes 正在准备会话",
"agent": "hermes",
"step": "加载 Hermes runtime",
"steps": [
"恢复会话上下文",
"调用 Hermes AIAgent",
"回传流式回复",
],
}
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
loop = asyncio.get_running_loop()
result_box: dict[str, Any] = {"content": None, "error": None, "model": prepared.model_name or "hermes"}
def stream_callback(delta: str) -> None:
loop.call_soon_threadsafe(queue.put_nowait, {"type": "chunk", "content": delta})
def run_sync() -> None:
try:
agent = self._build_agent(prepared, handle.hermes_session_id)
result = agent.run_conversation(
prepared.full_message,
system_message=self._build_system_message(prepared),
stream_callback=stream_callback,
)
result_box["content"] = str(result.get("final_response") or "")
result_box["model"] = getattr(agent, "model", prepared.model_name or "hermes")
except Exception as exc: # pragma: no cover - surfaced through queue
result_box["error"] = f"Hermes 执行失败: {exc}"
loop.call_soon_threadsafe(
queue.put_nowait,
{"type": "error", "error": result_box["error"]},
)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
worker = asyncio.create_task(asyncio.to_thread(run_sync))
streamed_text = ""
while True:
event = await queue.get()
if event is None:
break
if event.get("type") == "chunk":
streamed_text += str(event.get("content", ""))
yield event
await worker
handle.last_used_at = datetime.now(UTC)
handle.metadata = {
"session_id": handle.hermes_session_id,
"model": result_box["model"],
"last_error": result_box["error"],
}
final_text = result_box["content"] or streamed_text
if final_text and final_text != streamed_text:
yield {"type": "chunk", "content": final_text}
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
handle = hermes_session_manager.get_or_create(
conversation_id=prepared.conversation.id,
user_id=prepared.user.id,
)
async with handle.lock:
agent = await asyncio.to_thread(self._build_agent, prepared, handle.hermes_session_id)
result = await asyncio.to_thread(
agent.run_conversation,
prepared.full_message,
self._build_system_message(prepared),
)
handle.last_used_at = datetime.now(UTC)
resolved_model = getattr(agent, "model", prepared.model_name or "hermes")
handle.metadata = {
"session_id": handle.hermes_session_id,
"model": resolved_model,
"last_error": None,
}
return str(result.get("final_response") or ""), resolved_model
hermes_runtime_adapter = HermesRuntimeAdapter()

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@dataclass(slots=True)
class HermesSessionHandle:
conversation_id: str
user_id: str
hermes_session_id: str
last_used_at: datetime = field(default_factory=lambda: datetime.now(UTC))
restart_count: int = 0
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
metadata: dict[str, Any] = field(default_factory=dict)
class HermesSessionManager:
def __init__(self) -> None:
self._sessions: dict[str, HermesSessionHandle] = {}
def get_or_create(self, *, conversation_id: str, user_id: str) -> HermesSessionHandle:
handle = self._sessions.get(conversation_id)
if handle is None:
handle = HermesSessionHandle(
conversation_id=conversation_id,
user_id=user_id,
hermes_session_id=f"jarvis-{conversation_id}",
)
self._sessions[conversation_id] = handle
handle.last_used_at = datetime.now(UTC)
return handle
hermes_session_manager = HermesSessionManager()

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
class JarvisRuntimeAdapter(ChatRuntime):
name = "jarvis"
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]:
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
jarvis_runtime_adapter = JarvisRuntimeAdapter()

View File

@@ -42,6 +42,9 @@ from app.services.rollback_controller import RollbackController
from app.services.runtime_observability import build_runtime_observability_report
from app.agents.tools.time_reasoning import extract_reference_datetime
from app.agents.state import initial_state
from app.services.agent_runtime.base import RuntimePreparedContext
from app.services.agent_runtime.hermes_runtime import hermes_runtime_adapter
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
logger = logging.getLogger(__name__)
@@ -378,6 +381,9 @@ class AgentService:
def __init__(self, db: AsyncSession):
self.db = db
def _resolve_runtime(self, runtime: str | None) -> str:
return runtime or "jarvis"
async def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
async with async_session() as session:
await memory_service.try_auto_summarize(session, user_id, conversation_id)
@@ -662,10 +668,12 @@ class AgentService:
conversation_id: str | None = None,
file_ids: list[str] | None = None,
model_name: str | None = None,
runtime: str | None = None,
) -> tuple[str, str, AsyncGenerator[dict[str, Any], None]]:
"""
处理对话请求(流式)
"""
runtime_name = self._resolve_runtime(runtime)
user_llm_config = await self._get_user_llm_config(user_id, model_name)
model_name_used = model_name
if model_name and not user_llm_config:
@@ -758,7 +766,7 @@ class AgentService:
conversation_id=conversation_id,
role="assistant",
content="",
model=model_name_used or "jarvis",
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
attachments=None,
)
self.db.add(assistant_msg)
@@ -773,10 +781,78 @@ class AgentService:
"title": "Assistant message",
"content_summary": content[:500],
"raw_excerpt": content[:2000],
"metadata_": {"role": "assistant"},
"metadata_": {"role": "assistant", "runtime": runtime_name},
"importance_signal": 0.8,
}
if runtime_name == "hermes":
user = await self.db.get(User, user_id)
if user is None:
raise ValueError("用户不存在")
prepared = RuntimePreparedContext(
user=user,
conversation=conv,
user_message=user_msg,
assistant_message=assistant_msg,
raw_message=message,
full_message=full_message,
file_ids=file_ids or [],
model_name=model_name_used,
memory_context=memory_ctx,
)
async def run_hermes():
collected = ""
stream_failed = False
try:
async for event in hermes_runtime_adapter.chat_stream(prepared):
if event.get("type") == "chunk":
collected += str(event.get("content", ""))
elif event.get("type") == "error":
stream_failed = True
yield event
finally:
try:
session_handle = hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
)
assistant_msg.content = collected if collected else ("Hermes 执行失败,请检查运行配置。" if stream_failed else "")
assistant_msg.model = str(session_handle.metadata.get("model") or "hermes")
assistant_msg.attachments = [
{
"kind": "runtime_info",
"runtime": "hermes",
"session_id": session_handle.hermes_session_id,
"model": session_handle.metadata.get("model"),
"last_error": session_handle.metadata.get("last_error"),
}
]
conv.agent_state = {
"runtime": "hermes",
"runtime_state": {
"hermes": {
"session_id": session_handle.hermes_session_id,
"message_id": assistant_msg.id,
"model": session_handle.metadata.get("model"),
"last_error": session_handle.metadata.get("last_error"),
}
},
}
await BrainService(self.db).create_event(
user_id,
**_build_assistant_event_payload(assistant_msg.content),
)
await self.db.commit()
await self.db.refresh(assistant_msg)
except Exception:
logger.exception("save_hermes_assistant_message_failed")
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
asyncio.create_task(self._extract_memories_background(user_id, conversation_id))
return conversation_id, assistant_msg.id, run_hermes()
async def run_agent():
collected = ""
state: dict[str, Any] | None = None
@@ -1003,10 +1079,12 @@ class AgentService:
conversation_id: str | None = None,
file_ids: list[str] | None = None,
model_name: str | None = None,
runtime: str | None = None,
) -> tuple[str, str, str, str | None]:
"""
简单同步版对话
"""
runtime_name = self._resolve_runtime(runtime)
user_llm_config = await self._get_user_llm_config(user_id, model_name)
model_name_used = model_name
if model_name and not user_llm_config:
@@ -1043,7 +1121,7 @@ class AgentService:
conversation_id=conversation_id,
role="assistant",
content="",
model=model_name_used or "jarvis",
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
attachments=None,
)
self.db.add(assistant_msg)
@@ -1072,6 +1150,70 @@ class AgentService:
if recall_ctx:
memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx
if runtime_name == "hermes":
user = await self.db.get(User, user_id)
if user is None:
raise ValueError("用户不存在")
prepared = RuntimePreparedContext(
user=user,
conversation=conv,
user_message=user_msg,
assistant_message=assistant_msg,
raw_message=message,
full_message=message,
file_ids=file_ids or [],
model_name=model_name_used,
memory_context=memory_ctx,
)
response_content, resolved_model_name = await hermes_runtime_adapter.chat_once(prepared)
assistant_msg.content = response_content
assistant_msg.model = resolved_model_name or "hermes"
assistant_msg.attachments = [{
"kind": "runtime_info",
"runtime": "hermes",
"session_id": hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
).hermes_session_id,
"model": resolved_model_name,
}]
conv.agent_state = {
"runtime": "hermes",
"runtime_state": {
"hermes": {
"session_id": hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
).hermes_session_id,
"message_id": assistant_msg.id,
"model": resolved_model_name,
}
},
}
await brain_service.create_event(
user_id,
source_type="conversation",
source_id=conversation_id,
event_type="message_created",
title="Assistant message",
content_summary=response_content[:500],
raw_excerpt=response_content[:2000],
metadata_={"role": "assistant", "runtime": "hermes"},
importance_signal=0.8,
)
await self.db.commit()
await self.db.refresh(assistant_msg)
schedule_retrospective_job(
user_id=user_id,
conversation_id=conversation_id,
request_message_id=user_msg.id,
response_message_id=assistant_msg.id,
query_text=message,
final_response=response_content,
state=None,
)
return conversation_id, assistant_msg.id, response_content, assistant_msg.model
set_current_user(user_id)
try:
graph = get_agent_graph()

View File

@@ -0,0 +1,238 @@
import asyncio
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import object_session
from sqlalchemy.orm import selectinload
from app.models.task import (
Task,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskStatus,
TaskSubTask,
)
def _now() -> datetime:
return datetime.now(UTC)
def _stringify(value: object | None) -> str | None:
if value is None:
return None
return str(value)
def append_task_history(
task: Task,
*,
action: str,
old_value: object | None = None,
new_value: object | None = None,
) -> None:
entry = TaskHistory(
task_id=task.id,
action=action,
old_value=_stringify(old_value),
new_value=_stringify(new_value),
)
session = object_session(task)
if session is not None:
session.add(entry)
return
task.history.append(entry)
def build_dispatch_payload(task: Task, subtasks: list[TaskSubTask]) -> dict[str, object]:
return {
"business_task_id": task.id,
"title": task.title,
"description": task.description,
"priority": task.priority.value if isinstance(task.priority, TaskPriority) else str(task.priority),
"due_date": task.due_date.isoformat() if task.due_date else None,
"conversation_id": task.conversation_id,
"user_id": task.user_id,
"subtasks": [
{
"id": item.id,
"title": item.title,
"description": item.description,
"status": item.status.value if isinstance(item.status, TaskStatus) else str(item.status),
"assignee_type": item.assignee_type.value if item.assignee_type else None,
"assignee_id": item.assignee_id,
"dispatch_status": (
item.dispatch_status.value
if isinstance(item.dispatch_status, TaskDispatchStatus)
else str(item.dispatch_status)
),
"order_index": item.order_index,
}
for item in subtasks
],
}
async def _run_dispatch_flow(
task_id: str,
run_id: str,
*,
session_factory,
subtask_id: str | None = None,
) -> None:
await asyncio.sleep(0.01)
async with session_factory() as db:
task = await db.get(Task, task_id)
if task is None:
return
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
if subtask_id and target is None:
return
if subtask_id:
previous = target.dispatch_status
target.dispatch_status = TaskDispatchStatus.RUNNING
target.dispatch_run_id = run_id
target.completed_at = None
task.dispatch_status = TaskDispatchStatus.RUNNING
task.dispatch_run_id = run_id
task.started_at = task.started_at or _now()
task.last_synced_at = _now()
append_task_history(
task,
action="dispatch_status_changed",
old_value=f"{subtask_id}:{previous.value}",
new_value=f"{subtask_id}:{TaskDispatchStatus.RUNNING.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.RUNNING
task.dispatch_run_id = run_id
task.started_at = task.started_at or _now()
task.last_synced_at = _now()
task.status = TaskStatus.IN_PROGRESS
append_task_history(
task,
action="dispatch_status_changed",
old_value=previous.value,
new_value=TaskDispatchStatus.RUNNING.value,
)
await db.commit()
await asyncio.sleep(0.01)
async with session_factory() as db:
task = await db.get(Task, task_id)
if task is None:
return
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
if subtask_id and target is None:
return
synced_at = _now()
if subtask_id:
previous = target.dispatch_status
target.dispatch_status = TaskDispatchStatus.COMPLETED
target.dispatch_run_id = run_id
target.status = TaskStatus.DONE
target.completed_at = synced_at
task.dispatch_status = TaskDispatchStatus.COMPLETED
task.dispatch_run_id = run_id
task.result_summary = f"Commander completed subtask {target.title}"
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatch_status_changed",
old_value=f"{subtask_id}:{previous.value}",
new_value=f"{subtask_id}:{TaskDispatchStatus.COMPLETED.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.COMPLETED
task.dispatch_run_id = run_id
task.result_summary = f"Commander completed task {task.title}"
task.last_synced_at = synced_at
task.status = TaskStatus.DONE
task.completed_at = synced_at
append_task_history(
task,
action="dispatch_status_changed",
old_value=previous.value,
new_value=TaskDispatchStatus.COMPLETED.value,
)
await db.commit()
def schedule_dispatch(task_id: str, run_id: str, *, session_factory, subtask_id: str | None = None) -> None:
asyncio.create_task(
_run_dispatch_flow(
task_id,
run_id,
session_factory=session_factory,
subtask_id=subtask_id,
)
)
async def queue_task_dispatch(
task: Task,
*,
db,
subtask: TaskSubTask | None = None,
) -> tuple[str, dict[str, object]]:
subtasks = list(task.subtasks)
run_id = uuid4().hex[:12]
synced_at = _now()
if subtask is not None:
previous = subtask.dispatch_status
subtask.dispatch_status = TaskDispatchStatus.QUEUED
subtask.dispatch_run_id = run_id
task.dispatch_status = TaskDispatchStatus.QUEUED
task.dispatch_run_id = run_id
task.result_summary = None
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatched_to_commander",
old_value=f"{subtask.id}:{previous.value}",
new_value=f"{subtask.id}:{TaskDispatchStatus.QUEUED.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.QUEUED
task.dispatch_run_id = run_id
task.result_summary = None
task.started_at = None
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatched_to_commander",
old_value=previous.value,
new_value=TaskDispatchStatus.QUEUED.value,
)
await db.commit()
await db.refresh(task)
payload = build_dispatch_payload(task, subtasks)
session_factory = async_sessionmaker(bind=db.bind, expire_on_commit=False)
schedule_dispatch(
task.id,
run_id,
session_factory=session_factory,
subtask_id=subtask.id if subtask else None,
)
return run_id, payload
async def load_task_with_details(db, *, task_id: str, user_id: str) -> Task | None:
result = await db.execute(
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(Task.id == task_id, Task.user_id == user_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.agent_runtime.base import RuntimePreparedContext
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
class FakeAgent:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.model = kwargs.get("model", "fake-hermes-model")
def run_conversation(self, user_message, system_message=None, stream_callback=None):
if stream_callback is not None:
stream_callback("hello ")
stream_callback("world")
return {"final_response": "hello world"}
@pytest.fixture(autouse=True)
def clear_hermes_sessions():
hermes_session_manager._sessions.clear()
yield
hermes_session_manager._sessions.clear()
@pytest.fixture
def prepared_context():
return RuntimePreparedContext(
user=SimpleNamespace(id="user-1"),
conversation=SimpleNamespace(id="conv-1"),
user_message=SimpleNamespace(id="msg-user"),
assistant_message=SimpleNamespace(id="msg-assistant"),
raw_message="hi",
full_message="hi",
file_ids=[],
model_name="hermes-test-model",
memory_context="memory block",
)
@pytest.mark.asyncio
async def test_chat_once_calls_ai_agent(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
content, model = await adapter.chat_once(prepared_context)
assert content == "hello world"
assert model == "hermes-test-model"
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None
@pytest.mark.asyncio
async def test_chat_stream_emits_progress_and_chunks(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
events = []
async for event in adapter.chat_stream(prepared_context):
events.append(event)
assert events[0]["type"] == "progress"
chunks = [event["content"] for event in events if event["type"] == "chunk"]
assert "hello " in chunks
assert "world" in chunks
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None

View File

@@ -95,3 +95,65 @@ async def test_chat_stream_emits_error_event_when_agent_service_fails_before_str
assert response.status_code == 200
assert 'event: error' in response.text
assert 'stream boot failed' in response.text
@pytest.mark.asyncio
async def test_chat_stream_passes_runtime_to_agent_service(conversation_env, monkeypatch):
recorded: dict[str, object] = {}
async def fake_chat(self, **kwargs):
recorded.update(kwargs)
async def empty_stream():
if False:
yield None
return 'conv-rt', 'msg-rt', empty_stream()
monkeypatch.setattr('app.routers.conversation.AgentService.chat', fake_chat)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat/stream',
json={'message': 'hello', 'runtime': 'hermes'},
)
assert response.status_code == 200
assert recorded['runtime'] == 'hermes'
@pytest.mark.asyncio
async def test_chat_defaults_agent_name_to_jarvis(conversation_env, monkeypatch):
async def fake_chat_simple(self, **kwargs):
return 'conv-id', 'msg-id', 'ok', 'test-model'
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat',
json={'message': 'hello'},
)
assert response.status_code == 200
assert response.json()['agent_name'] == 'jarvis'
@pytest.mark.asyncio
async def test_chat_returns_hermes_agent_name_when_requested(conversation_env, monkeypatch):
async def fake_chat_simple(self, **kwargs):
return 'conv-id', 'msg-id', 'ok', 'hermes-model'
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat',
json={'message': 'hello', 'runtime': 'hermes'},
)
assert response.status_code == 200
assert response.json()['agent_name'] == 'hermes'

View File

@@ -2,7 +2,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns
from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns, ensure_task_columns
@pytest.mark.anyio
@@ -113,3 +113,70 @@ async def test_ensure_learning_artifact_tables_creates_table_and_indexes(tmp_pat
assert 'ix_learning_artifacts_artifact_type' in index_names
await engine.dispose()
@pytest.mark.anyio
async def test_ensure_task_columns_adds_today_status_columns_and_subtask_table(tmp_path):
db_path = tmp_path / 'test_tasks.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
async with engine.begin() as conn:
await conn.execute(text(
'''
CREATE TABLE tasks (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL,
priority VARCHAR(20) NOT NULL,
due_date DATETIME,
completed_at DATETIME,
tags VARCHAR(1000),
created_at DATETIME,
updated_at DATETIME
)
'''
))
await conn.execute(text(
'''
CREATE TABLE task_histories (
id VARCHAR(36) PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
action VARCHAR(100) NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME,
updated_at DATETIME
)
'''
))
await ensure_task_columns(conn)
task_columns = await conn.execute(text("PRAGMA table_info(tasks)"))
task_column_names = {row[1] for row in task_columns.fetchall()}
assert 'source' in task_column_names
assert 'conversation_id' in task_column_names
assert 'quadrant' in task_column_names
assert 'assignee_type' in task_column_names
assert 'dispatch_status' in task_column_names
assert 'dispatch_run_id' in task_column_names
assert 'last_synced_at' in task_column_names
history_columns = await conn.execute(text("PRAGMA table_info(task_histories)"))
history_column_names = {row[1] for row in history_columns.fetchall()}
assert 'subtask_id' in history_column_names
subtask_columns = await conn.execute(text("PRAGMA table_info(task_subtasks)"))
subtask_column_names = {row[1] for row in subtask_columns.fetchall()}
assert 'task_id' in subtask_column_names
assert 'order_index' in subtask_column_names
assert 'dispatch_status' in subtask_column_names
indexes = await conn.execute(text("PRAGMA index_list(task_subtasks)"))
index_names = {row[1] for row in indexes.fetchall()}
assert 'ix_task_subtasks_task_id' in index_names
assert 'ix_task_subtasks_dispatch_status' in index_names
await engine.dispose()

View File

@@ -1,10 +1,11 @@
import sys
from datetime import UTC, date, datetime
from datetime import UTC, datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
sys.modules.setdefault('psutil', Mock())
@@ -13,7 +14,7 @@ import app.models # noqa: F401
from app.database import Base, get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority, TaskStatus
from app.models.task import DispatchStatus, Task, TaskPriority, TaskQuadrant, TaskStatus, TaskSubTask
from app.models.todo import DailyTodo, TodoSource
from app.models.user import User
from app.routers.auth import get_current_user
@@ -50,7 +51,7 @@ async def schedule_env(tmp_path):
session.add_all([user, other_user])
await session.flush()
session.add_all([
seeded_items = [
DailyTodo(
user_id=user.id,
title='Legacy todo',
@@ -78,13 +79,19 @@ async def schedule_env(tmp_path):
title='High priority task',
priority=TaskPriority.HIGH,
status=TaskStatus.TODO,
source='schedule_center',
quadrant=TaskQuadrant.URGENT_IMPORTANT,
due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC),
assignee_type='commander',
assignee_id='master',
dispatch_status=DispatchStatus.RUNNING,
),
Task(
user_id=user.id,
title='Urgent task next day',
priority=TaskPriority.URGENT,
status=TaskStatus.IN_PROGRESS,
quadrant=TaskQuadrant.NOT_URGENT_IMPORTANT,
due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC),
),
Task(
@@ -106,6 +113,30 @@ async def schedule_env(tmp_path):
note='Ship MVP',
goal_date='2026-04-10',
),
]
session.add_all(seeded_items)
await session.flush()
high_priority_task = next(item for item in seeded_items if isinstance(item, Task) and item.title == 'High priority task')
session.add_all([
TaskSubTask(
task_id=high_priority_task.id,
title='Commander follow-up',
status=TaskStatus.TODO,
order_index=0,
assignee_type='agent',
assignee_id='executor',
dispatch_status=DispatchStatus.QUEUED,
),
TaskSubTask(
task_id=high_priority_task.id,
title='Commander completed step',
status=TaskStatus.DONE,
order_index=1,
assignee_type='agent',
assignee_id='executor',
dispatch_status=DispatchStatus.COMPLETED,
completed_at=datetime(2026, 4, 10, 16, 0, tzinfo=UTC),
),
])
await session.commit()
await session.refresh(user)
@@ -211,10 +242,143 @@ async def test_get_schedule_center_date_returns_aggregated_resources(schedule_en
'reminder_total': 1,
'goal_total': 1,
}
assert [item['title'] for item in payload['focus_tasks']] == ['High priority task']
assert [item['id'] for item in payload['quadrants']] == [
'urgent-important',
'not-urgent-important',
'urgent-not-important',
'not-urgent-not-important',
]
assert payload['quadrants'][0]['tasks'][0]['title'] == 'High priority task'
assert payload['commander_summary'] == {
'total': 3,
'queued': 1,
'running': 1,
'completed': 1,
'failed': 0,
'overall_status': 'running',
}
assert [item['title'] for item in payload['reminders']] == ['Doctor reminder']
assert [item['title'] for item in payload['goals']] == ['Launch calendar beta']
@pytest.mark.asyncio
async def test_task_detail_and_subtask_crud(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Plan Today Status',
'description': 'Wire task detail and quadrants',
'priority': 'high',
'quadrant': 'urgent-important',
'source': 'today_status',
'assignee_type': 'commander',
},
)
task_id = create_response.json()['id']
subtask_create = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Model backend', 'assignee_type': 'executor'},
)
subtask_id = subtask_create.json()['id']
subtask_update = await client.patch(
f'/api/tasks/{task_id}/subtasks/{subtask_id}',
json={'status': 'done'},
)
detail_response = await client.get(f'/api/tasks/{task_id}')
reorder_response = await client.post(
f'/api/tasks/{task_id}/subtasks/reorder',
json={'items': [{'id': subtask_id, 'order_index': 0}]},
)
assert create_response.status_code == 201
assert subtask_create.status_code == 201
assert subtask_update.status_code == 200
assert detail_response.status_code == 200
assert reorder_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload['source'] == 'today_status'
assert detail_payload['quadrant'] == 'urgent-important'
assert detail_payload['subtasks'][0]['title'] == 'Model backend'
assert detail_payload['subtasks'][0]['status'] == 'done'
assert any(entry['action'] == 'subtask_created' for entry in detail_payload['history'])
@pytest.mark.asyncio
async def test_task_dispatch_updates_summary_and_detail(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={'title': 'Dispatch me', 'priority': 'urgent', 'due_date': '2026-04-10T09:00:00Z'},
)
task_id = create_response.json()['id']
dispatch_response = await client.post(
f'/api/tasks/{task_id}/dispatch',
json={'assignee_type': 'commander', 'note': 'Send to runtime'},
)
detail_response = await client.get(f'/api/tasks/{task_id}')
assert dispatch_response.status_code == 200
dispatch_payload = dispatch_response.json()
assert dispatch_payload['status'] == 'queued'
assert dispatch_payload['run_id']
detail_payload = detail_response.json()
assert detail_payload['dispatch_status'] == 'queued'
assert detail_payload['dispatch_summary']['status'] == 'queued'
assert any(entry['action'] == 'dispatched_to_commander' for entry in detail_payload['history'])
@pytest.mark.asyncio
async def test_schedule_center_created_task_is_visible_in_today_status_aggregate(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Schedule Center created task',
'source': 'schedule_center',
'priority': 'medium',
'quadrant': 'not-urgent-important',
'due_date': '2026-04-10T09:00:00Z',
},
)
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert create_response.status_code == 201
titles = [item['title'] for item in date_response.json()['tasks']]
assert 'Schedule Center created task' in titles
focus_titles = [item['title'] for item in date_response.json()['focus_tasks']]
assert 'Schedule Center created task' in focus_titles
@pytest.mark.asyncio
async def test_today_status_created_task_is_visible_in_schedule_center_aggregate(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Today Status created task',
'source': 'today_status',
'priority': 'high',
'quadrant': 'urgent-important',
'due_date': '2026-04-10T11:00:00Z',
},
)
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert create_response.status_code == 201
quadrant_titles = [
task['title']
for quadrant in date_response.json()['quadrants']
for task in quadrant['tasks']
]
assert 'Today Status created task' in quadrant_titles
@pytest.mark.asyncio
async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
transport = ASGITransport(app=schedule_env)
@@ -239,6 +403,65 @@ async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
assert day_11['high_priority_total'] == 1
@pytest.mark.asyncio
async def test_schedule_center_tolerates_legacy_lowercase_task_enum_values(schedule_env):
app = schedule_env
session_override = app.dependency_overrides[get_db]
async for session in session_override():
user_id = (
await session.execute(
text("SELECT id FROM users WHERE email = 'schedule@example.com'")
)
).scalar_one()
await session.execute(
text(
"""
INSERT INTO tasks (
id, created_at, updated_at, user_id, title, description,
status, priority, due_date, completed_at, tags, source,
conversation_id, quadrant, assignee_type, assignee_id,
dispatch_status, dispatch_run_id, result_summary, started_at, last_synced_at
) VALUES (
:id, :created_at, :updated_at, :user_id, :title, NULL,
:status, :priority, :due_date, NULL, NULL, :source,
NULL, :quadrant, :assignee_type, :assignee_id,
:dispatch_status, NULL, NULL, NULL, NULL
)
"""
),
{
'id': 'legacy-task-1',
'created_at': '2026-04-10 06:00:00',
'updated_at': '2026-04-10 06:00:00',
'user_id': user_id,
'title': 'Legacy lowercase enum task',
'status': 'todo',
'priority': 'high',
'due_date': '2026-04-10 09:00:00',
'source': 'manual',
'quadrant': 'urgent-important',
'assignee_type': 'commander',
'assignee_id': 'legacy-master',
'dispatch_status': 'queued',
},
)
await session.commit()
break
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
month_response = await client.get('/api/schedule-center/month', params={'year': 2026, 'month': 4})
assert date_response.status_code == 200
assert month_response.status_code == 200
date_payload = date_response.json()
month_payload = month_response.json()
assert 'Legacy lowercase enum task' in [item['title'] for item in date_payload['tasks']]
assert next(item for item in month_payload['days'] if item['date'] == '2026-04-10')['task_due_total'] == 2
@pytest.mark.asyncio
async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env):
transport = ASGITransport(app=schedule_env)

View File

@@ -0,0 +1,221 @@
import asyncio
import sys
from datetime import UTC, date, datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
sys.modules.setdefault('psutil', Mock())
import app.models # noqa: F401
from app.database import Base, get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.routers.schedule_center import router as schedule_center_router
from app.routers.task import router as task_router
from app.services.auth_service import get_password_hash
@pytest.fixture
async def task_env(tmp_path):
db_path = tmp_path / 'test_task_router.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
user = User(
username='task_user',
email='task@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Task Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
test_app = FastAPI()
test_app.include_router(task_router)
test_app.include_router(schedule_center_router)
test_app.dependency_overrides[get_db] = override_get_db
test_app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield test_app
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_create_task_and_get_detail_with_business_fields(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Prepare daily status',
'description': 'Assemble the updated today status payload',
'priority': 'high',
'due_date': '2026-04-10T09:00:00Z',
'tags': ['today-status', 'chat'],
'source': 'chat',
'conversation_id': 'conv-123',
'quadrant': 'urgent-important',
'assignee_type': 'commander',
'assignee_id': 'code_commander',
},
)
assert create_response.status_code == 201
created = create_response.json()
detail_response = await client.get(f"/api/tasks/{created['id']}")
assert detail_response.status_code == 200
payload = detail_response.json()
assert payload['title'] == 'Prepare daily status'
assert payload['tags'] == ['today-status', 'chat']
assert payload['source'] == 'chat'
assert payload['conversation_id'] == 'conv-123'
assert payload['quadrant'] == 'urgent-important'
assert payload['assignee_type'] == 'commander'
assert payload['assignee_id'] == 'code_commander'
assert payload['dispatch']['status'] == 'idle'
assert [item['action'] for item in payload['history'][:2]] == ['created_from_chat', 'created']
@pytest.mark.asyncio
async def test_subtask_crud_and_reorder(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Implement kanban detail',
'due_date': '2026-04-10T11:00:00Z',
'quadrant': 'not-urgent-important',
},
)
task_id = task_response.json()['id']
first_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Load task detail', 'assignee_type': 'agent', 'assignee_id': 'planner'},
)
second_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Persist task edits', 'assignee_type': 'agent', 'assignee_id': 'executor'},
)
first_id = first_subtask.json()['subtasks'][0]['id']
second_id = second_subtask.json()['subtasks'][1]['id']
update_response = await client.patch(
f'/api/tasks/{task_id}/subtasks/{first_id}',
json={'status': 'done'},
)
reorder_response = await client.post(
f'/api/tasks/{task_id}/subtasks/reorder',
json={'items': [{'id': first_id, 'order_index': 1}, {'id': second_id, 'order_index': 0}]},
)
assert update_response.status_code == 200
assert reorder_response.status_code == 200
reordered = reorder_response.json()
assert [item['id'] for item in reordered['subtasks']] == [second_id, first_id]
assert reordered['subtasks'][1]['status'] == 'done'
assert any(item['action'] == 'subtask_reordered' for item in reordered['history'])
@pytest.mark.asyncio
async def test_create_today_status_task_persists_status_and_subtasks(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': '今日看板任务',
'description': '来自今日状态看板',
'status': 'in_progress',
'priority': 'high',
'source': 'today_status',
'quadrant': 'urgent-important',
'subtasks': [
{'title': '子任务一', 'status': 'todo'},
{'title': '子任务二', 'status': 'done'},
],
},
)
assert create_response.status_code == 201
payload = create_response.json()
detail_response = await client.get(f"/api/tasks/{payload['id']}")
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload['status'] == 'in_progress'
assert detail_payload['source'] == 'today_status'
assert detail_payload['quadrant'] == 'urgent-important'
assert [item['title'] for item in detail_payload['subtasks']] == ['子任务一', '子任务二']
assert detail_payload['subtasks'][0]['order_index'] == 0
assert detail_payload['subtasks'][1]['order_index'] == 1
assert detail_payload['subtasks'][1]['status'] == 'done'
assert detail_payload['subtasks'][1]['completed_at'] is not None
@pytest.mark.asyncio
async def test_dispatch_updates_task_and_schedule_center_summary(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Dispatch to commander',
'priority': 'urgent',
'due_date': '2026-04-10T15:00:00Z',
'quadrant': 'urgent-important',
},
)
task_id = task_response.json()['id']
dispatch_response = await client.post(
f'/api/tasks/{task_id}/dispatch',
json={'target': 'commander'},
)
assert dispatch_response.status_code == 200
assert dispatch_response.json()['task']['dispatch']['status'] == 'queued'
await asyncio.sleep(0.18)
detail_response = await client.get(f'/api/tasks/{task_id}')
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert detail_response.status_code == 200
detail = detail_response.json()
assert detail['dispatch']['status'] == 'completed'
assert detail['status'] == 'done'
assert detail['dispatch']['run_id']
assert detail['dispatch']['result_summary']
assert date_response.status_code == 200
payload = date_response.json()
assert payload['commander_summary'] == {
'total': 1,
'queued': 0,
'running': 0,
'completed': 1,
'failed': 0,
}
assert payload['focus_tasks'][0]['id'] == task_id
quadrants = {item['id']: item for item in payload['quadrants']}
assert quadrants['urgent-important']['tasks'][0]['id'] == task_id

View File

@@ -0,0 +1,160 @@
# Hermes-first 重构计划索引
本目录用于沉淀 Jarvis 从“自研 agent 主流程 + Hermes 可选 adapter”转向 **Hermes-first 架构** 的分阶段计划。
目标不是把 Jarvis 砍掉重写,而是把架构中心调整为:
- **Hermes**:默认 execution core
- **Jarvis**product shell负责 chat UI、conversation/message 持久化、memory/knowledge/task、continuity、observability、rollback
---
## 当前目标
1. 不再把 Hermes 只看作可选 runtime而是作为默认核心方向。
2. 保留 Jarvis 的产品价值,不把业务层能力粗暴塞进 Hermes 黑盒。
3. 保证 chat 仍是连续会话体验,不接受每轮冷启动。
4. 保持现有 `/api/conversations/chat/stream` 与 SSE 契约稳定。
5. 保留迁移期 fallback / 回滚能力,不做不可逆替换。
---
## 文档说明
| 文件 | 说明 |
|------|------|
| `README.md` | 总览、阶段关系、总体原则 |
| `adr-hermes-first-architecture.md` | Hermes-first 的架构决策记录 |
| `phase-h0-ownership-and-adr.md` | ownership matrix、边界与成功标准 |
| `phase-h1-agent-service-inversion.md` | `AgentService` 从 runtime 本体转为产品层编排 |
| `phase-h2-continuity-envelope.md` | `Conversation.agent_state` 的 runtime-neutral envelope |
| `phase-h3-durable-session-lifecycle.md` | Hermes durable session lifecycle |
| `phase-h4-product-shell-assembly.md` | Jarvis product shell 的 pre-runtime assembly |
| `phase-h5-event-mapper-and-sse-contract.md` | Hermes event -> Jarvis SSE mapper |
| `phase-h6-frontend-hermes-first-session-model.md` | 前端从 runtime toggle 过渡到 Hermes-first session model |
| `phase-h7-default-rollout-and-fallback.md` | 默认切换、灰度、fallback 与回滚 |
| `checklist.md` | 分阶段执行清单 |
---
## 推荐阅读顺序
1. `adr-hermes-first-architecture.md`
2. `phase-h0-ownership-and-adr.md`
3. `phase-h1-agent-service-inversion.md`
4. `phase-h2-continuity-envelope.md`
5. `phase-h3-durable-session-lifecycle.md`
6. `phase-h4-product-shell-assembly.md`
7. `phase-h5-event-mapper-and-sse-contract.md`
8. `phase-h6-frontend-hermes-first-session-model.md`
9. `phase-h7-default-rollout-and-fallback.md`
10. `checklist.md`
---
## 当前总体状态2026-04-10
| Phase | 状态 | 说明 |
|------|------|------|
| H0 | 进行中 | 已明确从 adapter-first 转向 Hermes-first需要先补完整文档 |
| H1 | 待开始 | `AgentService` 仍过于集中Jarvis runtime 尚未完全 adapter 化 |
| H2 | 待开始 | `Conversation.agent_state` 尚未统一成 runtime-neutral envelope |
| H3 | 待开始 | `HermesSessionManager` 仍偏进程内原型 |
| H4 | 待开始 | Jarvis 的 memory/skills/task graph 仍需固化为 product shell 装配层 |
| H5 | 待开始 | SSE 兼容已初步存在,但缺少稳定事件映射边界 |
| H6 | 待开始 | 前端仍把 runtime 视作用户可切换字符串,而非 session model |
| H7 | 待开始 | 还没有服务端默认 runtime policy / rollout / fallback 策略 |
---
## 总体实施原则
1. **先文档后开发**:先写清楚阶段文档,再按文档开发。
2. **Hermes 做核心Jarvis 做产品**:不让 Jarvis 继续承担主 runtime 本体。
3. **连续对话优先**:必须支持 warm session / resumed session而不是每轮冷启动。
4. **契约稳定优先**:前端继续消费稳定 SSE不直接理解 Hermes 内部事件。
5. **渐进切换优先**:迁移期间保留 fallback 和回滚,不做一次性替换。
6. **复用优先**memory、skill shortlist、task graph、conversation persistence 尽量保留为 Jarvis 产品层能力。
---
## Ownership Matrix摘要
### Hermes Core
- session lifecycle
- runtime resume / recovery
- turn execution loop
- chunk streaming
- runtime-internal tool loop
### Jarvis Product Shell
- conversation/message persistence
- memory context assembly
- skill shortlist
- task graph
- product continuity
- SSE contract
- runtime observability
- rollout / fallback policy
### Shared Contracts
- runtime prepared context
- runtime event model
- continuity envelope
- health / metrics metadata
---
## 阶段依赖图
```text
H0 -> H1 -> H2 -> H3 -> H4 -> H5 -> H6 -> H7
```
说明:
- 没有 H1就无法真正把 Jarvis 从 runtime 本体降级为产品层。
- 没有 H2/H3就无法让 Hermes-first 具备可靠 continuity。
- 没有 H5/H6前端会被 Hermes 内部细节污染。
- 没有 H7就无法安全默认切换。
---
## 关键风险
1. 把 Hermes session id 错当成完整 continuity。
2. 让前端直接依赖 Hermes-native event 细节。
3. `AgentService` 持续膨胀成新的耦合中心。
4. runtime toggle 长期暴露为普通用户负担。
5. 只靠进程内 session manager缺少 durable 恢复。
6. 没有 rollback policy 就直接默认切换。
---
## 当前代码锚点
### Backend
- `backend/app/services/agent_service.py`
- `backend/app/services/agent_runtime/base.py`
- `backend/app/services/agent_runtime/hermes_runtime.py`
- `backend/app/services/agent_runtime/hermes_session_manager.py`
- `backend/app/models/conversation.py`
- `backend/app/schemas/conversation.py`
- `backend/app/routers/conversation.py`
### Frontend
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/pages/chat/index.vue`
- `frontend/src/stores/conversation.ts`
- `frontend/src/api/agent.ts`
---
## 预期阶段结论
当本轮文档与实施完成后,应该达到:
- Hermes 成为默认 execution core 的明确落地方向。
- Jarvis 保留为 product shell而不是继续扩展自研 runtime。
- chat 继续是消息流产品,不变成终端模拟器。
- 默认切换前拥有清晰的灰度、fallback、回滚策略。

View File

@@ -0,0 +1,84 @@
# ADRHermes-first 架构
## 状态
Accepted进入实施规划
## 背景
Jarvis 当前已经具备较强的自研 agent runtime 能力,但核心执行链路仍然偏自定义、偏集中式,导致:
- 执行 runtime 与产品层耦合过深
- Hermes 虽已接入真实 bridge但仍只是 adapter 分支
- 长驻 session、恢复、执行循环等能力没有形成更清晰的 runtime ownership
- 前端虽然能切 runtime但本质仍是 Jarvis-centered UX + backend branching
同时,用户明确表达:
- 更偏好 Hermes 的体系化能力
- 不希望继续扩展自研 agent 主链路
- 希望连续对话、常驻 session、不冷启动
- 要求先文档后开发
## 决策
采用 **Hermes-first architecture**
- Hermes 成为默认 execution core
- Jarvis 保留为 product shell
- 旧 Jarvis graph 在迁移期保留为 fallback / specialist path
- 前端继续使用 Jarvis chat product shell而不是直接暴露 Hermes 终端形态
- SSE 契约保持稳定,由 Jarvis 负责做 runtime event mapping
## 责任边界
### Hermes 负责
- session lifecycle
- runtime resume / restart / health
- execution loop
- chunk streaming
- runtime-internal tool orchestration
### Jarvis 负责
- conversation/message persistence
- memory / knowledge / retrospective assembly
- skill shortlist
- task graph shaping
- product continuity envelope
- SSE contract
- observability / metrics / attachments
- rollout / fallback / rollback policy
## 不采用的方案
### 方案 A继续保持 adapter-first
不采用原因:
- Hermes 长期只会是支线 runtime
- `AgentService` 会继续膨胀
- 无法真正把架构中心从 Jarvis runtime 本体迁走
### 方案 B直接删除 Jarvis自上而下改成 Hermes 原生产品
不采用原因:
- 会丢失 Jarvis 已有的 conversation、memory、task、业务层价值
- 缺乏迁移和回滚路径
- 风险过高
## 影响
正向影响:
- runtime 职责更清晰
- 长驻 session 方向更明确
- 后续维护重心从“造 runtime”转向“做产品能力”
负向影响:
- 迁移期需要同时维护 Hermes default path 与 Jarvis fallback path
- 需要重构 `AgentService` 和 continuity state
- 需要补 durable lifecycle 和 rollout policy
## 验收标准
1. Hermes 成为默认 execution core。
2. Jarvis 仍保留 product shell 能力。
3. 多轮对话 continuity 不依赖每轮冷启动。
4. SSE 前端契约保持稳定。
5. 默认切换有灰度与回滚路径。

View File

@@ -0,0 +1,65 @@
# Hermes-first 执行清单
## H0 Ownership / ADR
- [x] 新增 Hermes-first `README.md`
- [x] 新增 ADRHermes-first architecture
- [x] 明确 ownership matrix
- [x] 明确 Jarvis product shell 与 Hermes core 的边界
- [x] 明确成功标准与关键风险
## H1 AgentService 架构倒置
- [ ]`AgentService` 明确拆分为 assembly / dispatch / finalization
- [ ] 正式 adapter 化 Jarvis graph 路径
- [ ] 引入 runtime registry / factory
- [ ] 减少 `if runtime == ...` 的散落逻辑
- [ ] 保持 router / SSE 契约不破坏
## H2 Continuity Envelope
- [ ] 设计统一 `Conversation.agent_state` envelope
- [ ] 加入 `active_runtime`
- [ ] 加入 `runtime_state.hermes`
- [ ] 保留 Jarvis product continuity
- [ ] 增加 migration/version metadata
- [ ] 补充兼容旧状态读取策略
## H3 Durable Session Lifecycle
- [ ] 升级 `HermesSessionManager` 为 durable lifecycle manager
- [ ] 支持 warm / resumed / cold 状态
- [ ] 支持 hydrate / recreate / idle reclaim
- [ ] 增加 health / restart / stale session 处理
- [ ] 补充 session recovery 测试
## H4 Product Shell Assembly
- [ ] 固化 memory assembly
- [ ] 固化 skill shortlist assembly
- [ ] 固化 task graph assembly
- [ ] 把这些能力统一收敛到 prepared context
- [ ] 保证 Hermes 不直接吞掉产品层职责
## H5 Event Mapper / SSE
- [ ] 新增 Hermes event mapper 边界
- [ ] 保持 `metadata/progress/chunk/error/done`
- [ ] richer diagnostics 落到 observability / attachments
- [ ] 保证前端 parser 无需重写
## H6 Frontend Hermes-first Session Model
- [ ] 减少 `jarvis` 默认 runtime 假设
- [ ] 减少 Jarvis-specific runtime 文案耦合
- [ ] 提升 session/run metadata 为一等状态
- [ ] runtime toggle 收缩为灰度/调试能力
- [ ] 保持 chat 仍是消息流体验
## H7 Default Rollout / Fallback
- [ ] 引入默认 runtime policy
- [ ] 支持 cohort / feature flag rollout
- [ ] 保留 Jarvis graph fallback 路径
- [ ] 定义 rollback 条件与动作
- [ ] 用真实对话与指标验证默认切换时机

View File

@@ -0,0 +1,112 @@
# H-0 Hermes 现状探测与 PoC 边界
## 1. 目标
在不影响现有 Jarvis 主流程的前提下,确认 Hermes 是否适合作为 Jarvis chat 的可嵌入 runtime。
本阶段只回答 4 个问题:
1. Hermes 更适合如何接入Python API、单次 CLI、长驻 CLI、gateway还是其他形式
2. Hermes 是否支持 conversation 级别的长驻 session / resume
3. Hermes 是否能在后端被程序化调用,而不是只能人工交互?
4. Hermes 的接入是否能保持 Jarvis 现有 chat 页面协议稳定?
## 2. 当前已知信息
### 2.1 来自 Hermes 仓库的直接结论
- Hermes 主入口是 CLI`hermes`
- 提供 single query 模式:`-q` / `query`
- 提供 `resume` 机制
- 提供 gateway 模式
- README 明确说明:原生 Windows 不受支持,建议 WSL2
- `run_agent.py` 暴露了更直接的 Python 级 `chat(message, stream_callback=...)` 接口
- 内部有 SQLite session store说明其本身有 session persistence 概念
### 2.2 对 Jarvis 的意义
这说明 Hermes **不是只能人手操作的纯 TUI**,而是具备:
- 单次 query 入口
- session 恢复能力
- Python 层 chat 接口
- streaming callback 可能性
因此它存在被 Jarvis 后端托管成 runtime 的现实基础。
## 3. 本阶段输出
### 3.1 必须验证的能力
1. **安装方式**
- 是否能在当前环境隔离安装
- 是否需要迁移到 WSL2 才具备稳定运行条件
2. **非交互调用能力**
- 是否能用 CLI 单次 query 跑通
- 是否能用 Python 直接调用 `run_agent.py` 的 chat 接口
3. **session 能力**
- 是否能创建、恢复、复用 session
- 是否适合绑定 `conversation_id`
4. **输出接法**
- 是否能通过 callback / stdout 获取稳定文本流
- 是否可被映射成 Jarvis 现有 SSE 事件
### 3.2 不在本阶段做的事
- 不改现有 Jarvis 默认运行链路
- 不重写前端 chat 页面
- 不直接删除或停用 LangGraph 主流程
- 不引入一次性大迁移
## 4. 推荐 PoC 边界
### 4.1 推荐优先级
1. **优先验证 Python chat 接口**
- 理由:比解析 TUI 更稳
- 若可行,首版桥接应优先走这个路径
2. **其次验证 CLI 单次 query + resume**
- 作为备选方案
- 若 Python 接口不可控,可退而求其次
3. **最后才考虑 TUI/PTY 桥接**
- 成本高
- 不适合作为 Jarvis chat 的第一接法
### 4.2 PoC 成功标准
- 能在隔离环境中启动 Hermes
- 能程序化发送一条消息并得到结果
- 能确认 session 可复用或可 resume
- 能形成一个后端 runtime adapter 可实现的最小桥接思路
## 5. 可能结论及后续影响
### 结论 APython chat 接口稳定
- 最优方案
- H-1/H-2 直接围绕 Python adapter + session manager 展开
### 结论 BCLI `-q` + `resume` 稳定
- 可接受
- H-2 要更强调 session 句柄与进程生命周期管理
### 结论 C只能稳定跑 TUI
- 风险显著升高
- 需重新评估是否值得继续集成
### 结论 D当前环境无法稳定运行
- 可能需要 WSL2 或远程服务化托管
- 再决定是否继续推进
## 6. 验证清单
- [ ] 拉取 Hermes 仓库到隔离目录
- [ ] 明确 install 依赖与 Python 版本要求
- [ ] 确认单次 query 调用方式
- [ ] 确认 Python chat 接口是否可用
- [ ] 确认 session / resume 的可编程性
- [ ] 记录接入建议结论,作为 H-1 输入

View File

@@ -0,0 +1,90 @@
# H-1 Runtime Adapter 边界
## 1. 目标
在不改变现有 Jarvis 默认行为的前提下,先把 chat 主流程改造成**可切换 runtime** 的结构。
核心思想:
- router 不变
- SSE 契约尽量不变
- `AgentService` 内新增 runtime 分发边界
- Jarvis 先被包装成默认 runtime
- Hermes 作为显式实验 runtime 并存
## 2. 当前主链路
当前 chat 路径:
```text
frontend/useChatView.ts
-> frontend/api/conversation.ts
-> POST /api/conversations/chat/stream
-> backend/app/routers/conversation.py
-> backend/app/services/agent_service.py
-> backend/app/agents/graph.py
```
问题在于:
- `AgentService` 直接耦合 Jarvis 图运行时
- 没有 runtime selector
- Hermes 无法以低风险方式并入
## 3. 本阶段目标结构
```text
conversation router
-> AgentService
-> resolve runtime
-> JarvisRuntimeAdapter | HermesRuntimeAdapter
```
### 3.1 关键要求
1. Jarvis 仍为默认 runtime
2. 不改现有 URL 和 SSE event name
3. 前端只需要传一个可选 `runtime` 字段
4. backend 可以继续把 Hermes 视为“可插拔执行器”
## 4. 数据契约
建议在 chat request 中增加:
- `runtime: "jarvis" | "hermes" | null`
规则:
- `null` / 未传:默认 `jarvis`
- `jarvis`:保持现有行为
- `hermes`:转入 Hermes adapter
## 5. 推荐文件调整
### Backend
- `backend/app/schemas/conversation.py`
- 增加 runtime 字段
- `backend/app/services/agent_service.py`
- 增加 runtime 解析
- 增加 runtime dispatch
- 新目录:`backend/app/services/agent_runtime/`
- `base.py`
- `jarvis_runtime.py`
- `hermes_runtime.py`
### Frontend
- `frontend/src/api/conversation.ts`
- 请求体增加 runtime
- `frontend/src/pages/chat/composables/useChatView.ts`
- 增加 selectedRuntime 状态
## 6. 约束
- 本阶段不要求 Hermes 已经完整可运行
- 允许先落 Hermes adapter 骨架
- 但不允许破坏 Jarvis 现有路径
## 7. 完成标准
- [ ] `runtime` 字段进入 request schema
- [ ] backend 已有 runtime dispatch 入口
- [ ] Jarvis 仍能正常完成原有 chat / chat_stream
- [ ] Hermes 可以作为占位 runtime 被请求到
- [ ] SSE 事件协议未被破坏

View File

@@ -0,0 +1,107 @@
# H-2 长驻 Hermes Session Manager
## 1. 目标
让 Hermes 以 conversation 级别的长驻 session 运行,而不是每条消息都重新冷启动。
这是本次接入最关键的用户体验目标:
- 连续上下文
- 无缝多轮对话
- 降低重复初始化耗时
- 避免“每次都像重新开机”
## 2. 会话归属原则
Hermes session 以 `conversation_id` 作为主键绑定。
原因:
1. Jarvis 现有 chat 的持久化中心本来就是 conversation
2. 前后端现有逻辑都已围绕 conversation 组织
3. conversation 是最自然的“连续对话上下文容器”
必要时可组合:
- `user_id + conversation_id`
## 3. 会话管理职责
建议新增 `HermesSessionManager`,负责:
1. 根据 conversation 获取或创建 Hermes session
2. 保存内存态句柄
3. 记录 last_used 时间
4. 做每会话锁,防止并发 turn 污染
5. 做 idle timeout 回收
6. 在异常时受控重建 session
## 4. 与持久化层的关系
### 4.1 内存态
内存里保存:
- session handle
- lock
- last_used
- health status
- restart count
### 4.2 数据库存储
建议把 Hermes runtime 元数据落入 `Conversation.agent_state`,但不要覆盖现有 Jarvis continuity。
建议结构:
```json
{
"runtime": "jarvis | hermes",
"runtime_state": {
"jarvis": { ... },
"hermes": {
"session_id": "...",
"last_used_at": "...",
"restart_count": 0,
"status": "healthy"
}
}
}
```
这样支持:
- 并存
- 切换
- 回滚
- 不破坏旧 continuity 数据
## 5. 生命周期建议
```text
用户发起消息
-> 根据 conversation 找 session
-> 有则复用
-> 无则创建
-> 执行消息
-> 更新 last_used / 状态
-> 空闲超时后回收
```
### 5.1 回收策略
- conversation 长时间无活动后可回收
- 但回收前要把必要 runtime 元数据保存到 `agent_state`
### 5.2 异常策略
- 首次异常:尝试一次受控重建
- 重建失败:返回 clean error
- 不能因此破坏 Jarvis 默认路径
## 6. 关键设计约束
1. 一个 conversation 同一时刻只能有一个进行中的 Hermes turn
2. 不允许两个并发消息写进同一个 Hermes session
3. session manager 不能成为 Jarvis 主流程的单点故障
4. Hermes 失败时,不能污染 conversation 的历史结构
## 7. 完成标准
- [ ] `conversation_id` 能稳定映射到 Hermes session
- [ ] session 可复用,不是每轮冷启动
- [ ] 有 per-conversation lock
- [ ] 有 idle timeout / cleanup 机制
- [ ] 有 crash / recreate 基础机制
- [ ] metadata 可写入 `Conversation.agent_state`

View File

@@ -0,0 +1,102 @@
# H-3 Hermes Adapter 与上下文复用
## 1. 目标
Hermes 只作为新的执行 runtime 接进来,不重新发明一套 Jarvis memory / context / chat protocol。
也就是说:
- Jarvis 已有的上下文构建能力继续复用
- Hermes 输出被适配为现有 chat 消息流
- 前端尽量不理解 Hermes 内部细节
## 2. 可复用能力
### 2.1 Memory
- `backend/app/services/memory_service.py`
继续复用:
- conversation summary
- recalled memory
- user memory
- knowledge brain 注入
### 2.2 Skill shortlist
- `backend/app/agents/skills/retriever.py`
继续复用:
- request 相关 skill shortlist
### 2.3 Task graph
- `backend/app/agents/orchestration/task_graph.py`
继续复用:
- bounded task graph
- parallel worthiness 等前置分析
## 3. 推荐数据流
```text
AgentService
-> 读取 conversation / message / files
-> 构建 memory context
-> 构建 skill shortlist
-> 构建 task graph / runtime request context
-> 根据 runtime 分发
-> JarvisRuntimeAdapter
-> HermesRuntimeAdapter
```
这样 Hermes 看到的是**已整理好的 runtime context**,而不是被迫直接复用 Jarvis 图内部状态机。
## 4. SSE 契约保持不变
继续沿用现有事件:
- `metadata`
- `progress`
- `chunk`
- `error`
- `done`
### 4.1 原因
前端现有:
- `conversationApi.chatStream()` 已解析这套事件
- `useChatView.ts` 已依赖这套事件更新 thinking state / orchestration panel
如果这里大改,会让前端接入成本飙升。
### 4.2 Hermes event mapping
Hermes 内部即使没有完全等价事件,也应该适配成:
- 初始化 / session 准备 -> `progress`
- 实际文本输出 -> `chunk`
- 错误 -> `error`
- 完成 -> `done`
缺字段可以降级,但 event 名称不要改。
## 5. 持久化与可观测性
继续沿用:
- `Message` 表保存 user / assistant 内容
- `Conversation.agent_state` 保存 runtime continuity 元数据
- `attachments` 可用于记录 Hermes 运行附加信息
建议:
- 把 Hermes 观测信息放在 runtime-tagged attachment 中
- 不把探测日志直接渲染进用户可见消息正文
## 6. 边界约束
1. Hermes continuity 与 Jarvis continuity 分开存
2. 不要让 Hermes adapter 直接改写现有 Jarvis graph 状态格式
3. 前端不直接显示“终端字节流”
4. Hermes 适配失败时,必须 clean fail
## 7. 完成标准
- [ ] 现有 memory pipeline 可被 Hermes 复用
- [ ] 现有 skill shortlist / task graph 可被 Hermes 复用
- [ ] Hermes 输出成功映射到既有 SSE 契约
- [ ] assistant message 按现有结构持久化
- [ ] Hermes continuity 数据不覆盖 Jarvis continuity 数据

View File

@@ -0,0 +1,85 @@
# H-4 前端切换与并行评估
## 1. 目标
让 chat 页面在尽量不改变现有体验的前提下,支持切换 `jarvis | hermes`,并进入受控评估期。
重点不是做新 UI而是
- 能切换 runtime
- 能继续对话
- 能收集真实效果
- 不影响现有默认使用路径
## 2. 前端最小改动原则
### 2.1 继续复用现有页面
主要锚点:
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/index.vue`
### 2.2 最小改动内容
1. 增加 `selectedRuntime`
2. 在发送消息时把 runtime 放入 request body
3. 页面可加一个轻量 toggle / selector
4. 不改变现有消息渲染逻辑
5. 不把页面改造成“网页终端”
## 3. 评估期策略
### 3.1 默认值
- Jarvis 仍为默认 runtime
- Hermes 为显式选择项
### 3.2 评估维度
必须记录:
- 首 token 延迟
- 完整回复耗时
- 第二轮/第三轮连续对话体验
- session 是否稳定复用
- 工具调用效果
- memory 是否有效承接
- 异常率 / 重启率
- 开发维护复杂度
### 3.3 用户体验标准
如果 Hermes 要成为默认 runtime至少应满足
1. 不比 Jarvis 更割裂
2. 不出现频繁 session 丢失
3. 前端不需要额外理解复杂运行细节
4. 整体体验更像连续助手而不是一次性问答器
## 4. 验收建议
### Frontend
- [ ] Jarvis 默认聊天体验不变
- [ ] 可切换到 Hermes 并成功发消息
- [ ] 历史会话读取不崩
- [ ] orchestration panel 不因 Hermes 字段较少而崩溃
### Backend
- [ ] Hermes 路径不影响 Jarvis 默认路径
- [ ] SSE 解析不需要重写
- [ ] conversation/message 结构保持兼容
### Product
- [ ] 可以真实比较两个 runtime
- [ ] 结论可支持“继续替换”或“放弃替换”
## 5. 阶段结论输出
本阶段结束后,应明确给出以下结论之一:
### 结论 AHermes 明显更优
- 新开一轮“默认切换 / 逐步替换”规划
### 结论 BHermes 可保留为实验 runtime
- 不切默认
- 继续特定场景使用
### 结论 CHermes 不适合当前 Jarvis
- 中止替换计划
- 保留本轮探索结论供后续参考

View File

@@ -0,0 +1,87 @@
# H0 Ownership Matrix 与架构决策
## 1. 目标
先把 Hermes-first 的 ownership matrix、边界和成功标准写清楚防止后续开发过程中又回到“先写 adapter最后发现架构中心没变”。
## 2. 核心判断
这轮改造的目标不是:
- 给 Jarvis 再挂一个更强的 runtime adapter
而是:
- 让 Hermes 成为默认 execution core
- 让 Jarvis 退回 product shell
## 3. Ownership Matrix
### 3.1 Hermes Core
Hermes 应负责:
1. session lifecycle
2. runtime resume / recovery
3. execution loop
4. chunk streaming
5. runtime-internal tool orchestration
6. runtime health / restart metadata
### 3.2 Jarvis Product Shell
Jarvis 应负责:
1. conversation / message 持久化
2. user / auth / permission 边界
3. memory context assembly
4. retrospective / knowledge 注入
5. skill shortlist
6. task graph / parallel worthiness 分析
7. product continuity
8. SSE contract
9. observability / attachments / metrics
10. rollout / fallback / rollback policy
### 3.3 Shared Contracts
需要显式建模的共享边界:
1. runtime prepared context
2. runtime event model
3. continuity envelope
4. session health metadata
5. rollout policy metadata
## 4. 关键边界原则
1. 不把 Hermes session id 直接当成完整 continuity。
2. 不让前端直接依赖 Hermes-native event。
3. 不把 memory / skill / task graph 直接挪进 Hermes 黑盒。
4. 不让 `AgentService` 继续充当 runtime 本体。
5. 不把 runtime 选择长期暴露为普通用户负担。
## 5. 目标文件锚点
### Backend
- `backend/app/services/agent_service.py`
- `backend/app/services/agent_runtime/base.py`
- `backend/app/services/agent_runtime/hermes_runtime.py`
- `backend/app/services/agent_runtime/hermes_session_manager.py`
- `backend/app/models/conversation.py`
### Frontend
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/stores/conversation.ts`
## 6. 成功标准
- [ ] Hermes-first 的 ownership matrix 被写清楚
- [ ] 保留 Jarvis product shell 的范围被写清楚
- [ ] fallback / rollback 必须存在这一点被写清楚
- [ ] 后续 phase 的顺序依赖被写清楚
## 7. 本阶段结论
只有先把 ownership 定义清楚,后面 H1-H7 才不会演变成“代码改了很多,但架构中心没有变”。

View File

@@ -0,0 +1,82 @@
# H1 AgentService 架构倒置
## 1. 目标
`AgentService` 从“Jarvis runtime 本体”重构成“Jarvis 产品层编排器”。
也就是说,`AgentService` 只做三件事:
1. request assembly
2. runtime dispatch
3. finalization
## 2. 当前问题
当前 `backend/app/services/agent_service.py` 同时承载:
- memory / retrospective / skills / task graph 装配
- Jarvis graph 执行
- Hermes runtime dispatch
- SSE 流组装
- message / agent_state / observability 持久化
这导致:
- Hermes 只能是分支,不是核心
- Jarvis runtime 难以真正 adapter 化
- 后续 fallback / rollout 容易继续堆在一个文件里
## 3. 目标结构
```text
conversation router
-> AgentService
-> assemble runtime request
-> resolve runtime via registry/factory
-> dispatch to runtime adapter
-> finalize persistence and observability
```
## 4. 关键动作
### 4.1 Request Assembly
保留在 Jarvis product shell
- memory context
- retrospective
- skill shortlist
- task graph
- time context
- conversation continuity load
### 4.2 Runtime Dispatch
- 建立 runtime registry / factory
- `JarvisRuntimeAdapter` 正式承接旧 graph 路径
- `HermesRuntimeAdapter` 成为默认目标 runtime
- 避免 `if runtime == ...` 继续扩散
### 4.3 Finalization
- assistant message 落库
- attachments/runtime metadata 落库
- `Conversation.agent_state` 更新
- runtime observability report 持久化
## 5. 推荐文件变更
- `backend/app/services/agent_service.py`
- `backend/app/services/agent_runtime/base.py`
- `backend/app/services/agent_runtime/jarvis_runtime.py`
- `backend/app/services/agent_runtime/hermes_runtime.py`
- 可选新增:`backend/app/services/agent_runtime/registry.py`
## 6. 设计约束
1. 不破坏 router / API 路径。
2. 不改变前端 SSE 事件名。
3. Jarvis graph 在本阶段仍保留为 fallback。
4. 先把职责边界立住,再调整默认 runtime。
## 7. 完成标准
- [ ] `AgentService` 明确分为 assembly / dispatch / finalization
- [ ] Jarvis runtime 被正式 adapter 化
- [ ] Hermes path 不再只是散落的 if-branch
- [ ] 为 H2/H3 continuity 与 session lifecycle 留出清晰边界

View File

@@ -0,0 +1,76 @@
# H2 Continuity Envelope
## 1. 目标
`Conversation.agent_state` 从“当前 runtime 顺手写进去的状态桶”升级成 **runtime-neutral continuity envelope**
## 2. 当前问题
当前状态里:
- Jarvis continuity 已较丰富
- Hermes runtime metadata 仍较浅
- 两边并没有统一的 envelope
风险是:
- Hermes session state 覆盖 Jarvis continuity
- 回滚时状态结构混乱
- 后端重启后难以恢复 runtime continuity
## 3. 目标结构
建议方向:
```json
{
"version": 2,
"active_runtime": "hermes",
"runtime_state": {
"jarvis": { "...": "fallback/runtime snapshot" },
"hermes": {
"session_id": "...",
"status": "warm|resumed|cold|error",
"last_used_at": "...",
"restart_count": 0,
"health": { "...": "..." }
}
},
"product_continuity": {
"turn_context": {},
"pending_action": {},
"task_state": {},
"memory_checkpoint": {}
},
"migration": {
"source": "jarvis-legacy",
"updated_at": "..."
}
}
```
## 4. 核心原则
1. Jarvis 拥有产品 continuity。
2. Hermes 拥有 runtime continuity。
3. envelope 负责把两者挂在一起。
4. 不能让 Hermes session id 替代产品 continuity。
## 5. 影响范围
- `backend/app/models/conversation.py`
- `backend/app/services/agent_service.py`
- `backend/app/agents/state.py`
- `backend/app/services/agent_runtime/hermes_session_manager.py`
## 6. 历史兼容
本阶段必须考虑:
- 兼容旧 `agent_state`
- 兼容 Jarvis-only 历史 conversation
- 允许逐步迁移,不要求一次性重写所有旧数据
## 7. 完成标准
- [ ] `agent_state` 有统一 envelope 结构
- [ ] Jarvis continuity 与 Hermes runtime state 不再互相覆盖
- [ ] 老 conversation 可兼容读取
- [ ] 为 H3 durable lifecycle 提供恢复所需元数据

View File

@@ -0,0 +1,71 @@
# H3 Durable Session Lifecycle
## 1. 目标
`HermesSessionManager` 从“进程内 session 缓存”升级成支持恢复、重建、观测的 durable lifecycle manager。
## 2. 当前问题
当前 `backend/app/services/agent_runtime/hermes_session_manager.py` 已有:
- conversation -> session 基础映射
- per-conversation lock
- last_used / restart_count / metadata
但它仍然偏原型:
- 依赖当前进程内内存
- 后端重启后的恢复能力不足
- warm / resumed / cold 没有显式状态
- recovery policy 不够清晰
## 3. 生命周期目标
```text
message arrives
-> lookup by conversation
-> warm session exists? reuse
-> else hydrate from agent_state
-> if hydrate success => resumed
-> else create fresh => cold
-> execute turn
-> update state/metrics
-> idle reclaim if needed
```
## 4. 必要能力
1. warm / resumed / cold 状态区分
2. conversation 级别锁
3. runtime health 检查
4. restart / recreate 策略
5. idle reclaim
6. safe rehydrate
7. stale session 检测
8. error 状态记录
## 5. 与 envelope 的关系
持久化来源:
- `Conversation.agent_state.runtime_state.hermes`
运行态来源:
- `HermesSessionManager`
原则:
- warm session 提升性能
- durable metadata 保障可恢复性
- 不能要求一个 Hermes 进程永远不死
## 6. 推荐文件变更
- `backend/app/services/agent_runtime/hermes_session_manager.py`
- `backend/app/services/agent_runtime/hermes_runtime.py`
- `backend/app/services/agent_service.py`
- `backend/app/models/conversation.py`
- 新增或补充测试session resume / recreate / restart / idle reclaim
## 7. 完成标准
- [ ] conversation 能恢复到正确 Hermes session 或重建新 session
- [ ] warm / resumed / cold 状态可区分
- [ ] 后端重启后 continuity 不直接断裂
- [ ] recovery/failure 有清晰记录

View File

@@ -0,0 +1,83 @@
# H4 Product Shell Assembly
## 1. 目标
固化 Jarvis 在 Hermes-first 架构中的角色Jarvis 不是 runtime 本体,而是 **pre-runtime product shell**
## 2. 为什么保留 Jarvis Product Shell
如果直接把 memory / skill / task graph 也推给 Hermes
- Jarvis 已有产品价值会丢失
- 业务能力会进入黑盒
- 回滚与可观测性会变差
所以 Hermes-first 不等于“Jarvis 退化成纯 UI”。
## 3. 保留的装配能力
### 3.1 Memory Assembly
- `backend/app/services/memory_service.py`
继续负责:
- recalled memory
- conversation summary
- user memory
- knowledge brain 注入
### 3.2 Skill Shortlist
- `backend/app/agents/skills/retriever.py`
继续负责:
- request 相关 skill shortlist
- 激活建议
### 3.3 Task Graph / Runtime Request Context
- `backend/app/agents/orchestration/task_graph.py`
- `backend/app/agents/schemas/orchestration.py`
继续负责:
- bounded task graph
- parallel worthiness
- runtime request summary
### 3.4 Retrospective / Product Guardrails
- `backend/app/agents/learning/*`
- `backend/app/agents/tools/time_reasoning.py`
继续负责:
- retrospective 注入
- 时间上下文
- 产品级指令与 guardrails
## 4. 目标数据流
```text
router
-> AgentService
-> load conversation/product continuity
-> assemble memory/skills/task graph/retrospective/time context
-> build RuntimePreparedContext
-> dispatch to Hermes runtime
-> map events and finalize persistence
```
## 5. 关键原则
1. Hermes 接收的是已组装好的 Jarvis product context。
2. Jarvis 保留用户级/产品级理解能力。
3. 不把 Hermes 当作唯一知识与策略拥有者。
4. 装配层必须可测试、可观察、可回滚。
## 6. 推荐文件变更
- `backend/app/services/agent_service.py`
- `backend/app/services/memory_service.py`
- `backend/app/agents/skills/retriever.py`
- `backend/app/agents/orchestration/task_graph.py`
- `backend/app/services/agent_runtime/base.py`
## 7. 完成标准
- [ ] Jarvis product shell 的装配职责被明确固化
- [ ] Hermes 收到的是统一的 prepared context
- [ ] memory / skills / task graph 不被直接塞回 Hermes 黑盒内部

View File

@@ -0,0 +1,75 @@
# H5 Event Mapper 与 SSE 契约
## 1. 目标
建立一层稳定的 **Hermes event -> Jarvis SSE** 映射边界,让前端无需理解 Hermes 内部事件模型。
## 2. 当前约束
前端当前主要依赖:
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/composables/useChatView.ts`
并假设 SSE 事件名为:
- `metadata`
- `progress`
- `chunk`
- `error`
- `done`
Hermes-first 改造不能让 UI 直接去消费 Hermes-native event。
## 3. 推荐映射原则
### 3.1 保持外部契约稳定
对前端继续输出:
- `metadata`
- `progress`
- `chunk`
- `error`
- `done`
### 3.2 Hermes richer event 不直接外泄
更细的 runtime 细节:
- tool trace
- retry/restart details
- health transitions
- session diagnostics
应该进入:
- runtime observability
- attachments
- agent_state metadata
而不是直接让 UI 依赖这些字段。
## 4. 推荐映射关系
- session prepare / hydrate / warm reuse -> `progress`
- assistant content delta -> `chunk`
- execution failure -> `error`
- finish signal -> `done`
- conversation/message identity -> `metadata`
## 5. 推荐文件变更
- 新增:`backend/app/services/agent_runtime/hermes_event_mapper.py`
- 修改:
- `backend/app/services/agent_runtime/hermes_runtime.py`
- `backend/app/services/agent_service.py`
- `frontend/src/api/conversation.ts`(如需轻量兼容字段扩展)
## 6. 设计约束
1. SSE 事件名不破坏。
2. 前端 parser 不因 Hermes-first 被重写。
3. 缺失字段允许降级,但事件序列必须稳定。
4. 错误事件不能导致 assistant message 持久化乱序。
## 7. 完成标准
- [ ] Hermes runtime 有明确的 event mapping 边界
- [ ] 前端现有 SSE parser 继续可用
- [ ] richer diagnostics 有单独落点,不污染 UI 契约

View File

@@ -0,0 +1,68 @@
# H6 前端 Hermes-first Session Model
## 1. 目标
让前端从“runtime toggle + Jarvis 默认假设”过渡到 **Hermes-first session model**
## 2. 当前问题
当前前端已经支持:
- `runtime: 'jarvis' | 'hermes'`
但本质上仍存在:
- 默认 runtime = jarvis
- 大量 Jarvis-specific 文案与状态
- runtime 只是一个请求字符串
- session/run metadata 不是一等状态
## 3. 方向
前端长期目标不是让用户一直选 runtime而是
- 普通用户默认走 Hermes-first 路径
- runtime toggle 仅保留给灰度、调试、回滚
- conversation/session/run metadata 进入更明确的状态模型
## 4. 推荐调整点
### 4.1 Transport Layer
- `frontend/src/api/conversation.ts`
保持 transport 职责,不再承载过多 runtime 语义。
### 4.2 Chat Runtime State
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/stores/conversation.ts`
需要逐步减少:
- `selectedRuntime = 'jarvis'` 这类默认假设
- `jarvisNote` / `JARVIS THINKING` 等强绑定文案
- 仅靠本地拼装 model label 的方式
### 4.3 Session/Run Metadata
建议逐步提升为一等状态:
- active runtime
- run status
- session statuswarm/resumed/cold
- runtime diagnostics summary
## 5. UI 原则
1. chat 仍是消息流,不变成终端模拟器。
2. 普通用户不需要长期理解 runtime 架构。
3. 迁移期保留 toggle但它属于 admin/dev 工具。
4. 历史 conversation 渲染逻辑应尽量保持稳定。
## 6. 推荐文件变更
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/pages/chat/index.vue`
- `frontend/src/stores/conversation.ts`
- `frontend/src/api/agent.ts`
## 7. 完成标准
- [ ] 前端减少 Jarvis-default 假设
- [ ] runtime toggle 可转向灰度/调试用途
- [ ] session/run metadata 开始成为一等状态
- [ ] 普通聊天体验保持稳定

View File

@@ -0,0 +1,66 @@
# H7 默认切换、灰度与 Fallback
## 1. 目标
让 Hermes 从“可选 runtime”安全过渡为默认路径同时保留 fallback 与回滚能力。
## 2. 核心原则
1. 不做一次性全量切换。
2. 默认切换必须由服务端策略控制。
3. Jarvis graph 在迁移期保留为 fallback / specialist path。
4. 回滚不能破坏 conversation/message 数据。
## 3. 推荐 rollout 顺序
### Stage 1显式 opt-in
- 仅对开发/内部用户开放
- 用户可显式选择 Hermes
### Stage 2内部默认 Hermes
- 对内部账号或灰度 cohort 默认走 Hermes
- 保留 fallback 开关
### Stage 3新会话默认 Hermes
- 仅新 conversation 默认 Hermes
- 历史 conversation 可按策略维持原路径或迁移
### Stage 4全量默认 Hermes
- 普通用户默认走 Hermes-first
- runtime toggle 从主 UI 淡出
## 4. Fallback 策略
触发 fallback 的场景示例:
- Hermes session hydrate 失败
- Hermes runtime health 不达标
- 连续 error/restart 达到阈值
- 某类 specialist workflow 尚未迁移
fallback 目标:
- 切回 Jarvis graph 或受控备用路径
- 不丢 conversation continuity
- 不破坏前端 SSE 契约
## 5. 回滚要求
1. 服务端可按 feature flag / policy 回滚默认 runtime。
2. 不要求修改前端主交互逻辑即可回滚。
3. 历史 conversation 的 `agent_state` 仍能兼容读取。
4. 回滚后仍可保留 Hermes metrics 用于复盘。
## 6. 推荐文件变更
- `backend/app/services/agent_service.py`
- `backend/app/routers/conversation.py`
- `backend/app/schemas/conversation.py`
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/pages/chat/index.vue`
- 可能新增runtime policy / feature flag 配置模块
## 7. 验收标准
- [ ] 有清晰 rollout policy
- [ ] 有 fallback 策略
- [ ] 有 rollback 机制
- [ ] Hermes 默认切换不会成为不可逆操作

View File

@@ -0,0 +1,122 @@
# Today Status 完整化实施计划索引
本目录用于存放首页 `Today Status` 完整化的分阶段规划文档,目标是先完成文档拆解,再交给 Codex 按阶段实施。
## 文档说明
| 文件 | 说明 |
|------|------|
| `README.md` | 总览、阶段关系、实施顺序、关键文件 |
| `phase-ts-0-current-state.md` | 当前现状、问题、目标架构 |
| `phase-ts-1-business-task-model.md` | 业务 Task / SubTask / 分配模型扩展 |
| `phase-ts-2-task-api-and-schedule-aggregation.md` | Task API 与 Schedule Center 聚合扩展 |
| `phase-ts-3-chat-today-status-integration.md` | Chat 首页 Today Status 接真实数据 |
| `phase-ts-4-manual-create-and-detail-editor.md` | 手动创建与详情编辑器 |
| `phase-ts-5-commander-dispatch.md` | Commander 派发闭环 |
| `checklist.md` | 给 Codex 使用的可勾选执行清单 |
## 推荐阅读顺序
1. 先阅读 `phase-ts-0-current-state.md`
2. 再按顺序阅读 `phase-ts-1` ~ `phase-ts-5`
3. 实施时严格按阶段推进
---
## 总体设计原则
1. **业务任务与执行态分层**
- 业务 Task / SubTask 不直接等于 runtime task graph。
2. **Today Status 复用 Schedule Center 真实聚合**
- 不新增第二套聚合真源。
3. **Chat 创建先走显式入口**
- 第一版优先 `/task``/task@commander` 之类显式方式。
4. **先数据闭环,后体验增强**
- 先打通 CRUD / 聚合 / dispatch再补评论、自动识别、实时推送。
5. **文档先行,代码交给 Codex**
- 这组文档的目标是让 Codex 可以按阶段稳定实施。
---
## 阶段总览图
```text
Phase TS-0 ───────────────────────────────────────────────────────────┐
│ 当前现状与目标 │
│ - 真实数据流盘点 │
│ - mock 边界盘点 │
│ - 目标三层架构 │
└───────────────────────────────────────────────────────────────────┘
Phase TS-1 ───────────────────────────────────────────────────────────┐
│ 业务任务模型扩展 │
│ - Task 扩字段 │
│ - 新增业务级 TaskSubTask │
│ - TaskHistory 动作扩展 │
└───────────────────────────────────────────────────────────────────┘
Phase TS-2 ───────────────────────────────────────────────────────────┐
│ Task API 与 Schedule 聚合扩展 │
│ - task detail / subtasks / dispatch │
│ - schedule-center/date 扩展 focus/quadrants/commander summary │
└───────────────────────────────────────────────────────────────────┘
Phase TS-3 ───────────────────────────────────────────────────────────┐
│ Chat 首页 Today Status 接真实数据 │
│ - useSidebarPlan 去 mock │
│ - KanbanPanel 真实化 │
│ - Chat 首页联动刷新 │
└───────────────────────────────────────────────────────────────────┘
Phase TS-4 ───────────────────────────────────────────────────────────┐
│ 手动创建与详情编辑器 │
│ - KanbanDetail 真实 create/edit │
│ - Schedule Center 手动创建增强 │
│ - 象限快捷新建 │
└───────────────────────────────────────────────────────────────────┘
Phase TS-5 ───────────────────────────────────────────────────────────┐
│ Commander 派发闭环 │
│ - task/subtask dispatch API │
│ - commander 执行态回写 │
│ - Today Status / Schedule Center 状态一致 │
└───────────────────────────────────────────────────────────────────┘
```
---
## 关键文件总览
### Backend
- `backend/app/models/task.py`
- `backend/app/schemas/task.py`
- `backend/app/routers/task.py`
- `backend/app/routers/schedule_center.py`
- `backend/app/schemas/schedule_center.py`
- commander / orchestration service 相关文件
### Frontend
- `frontend/src/api/task.ts`
- `frontend/src/api/scheduleCenter.ts`
- `frontend/src/pages/chat/composables/useSidebarPlan.ts`
- `frontend/src/pages/chat/index.vue`
- `frontend/src/components/chat/KanbanPanel.vue`
- `frontend/src/components/chat/KanbanDetail.vue`
- `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts`
- `frontend/src/pages/schedule-center/index.vue`
- Chat 输入 / 发送消息相关 composable
---
## 实施顺序
```text
TS-0 → TS-1 → TS-2 → TS-3 → TS-4 → TS-5
```
不建议跳阶段。尤其是 `TS-1``TS-2` 是后续前端改造和 commander 派发的共同前提。

View File

@@ -0,0 +1,92 @@
# Today Status 完整化执行清单(可勾选版)
日期2026-04-08
状态:执行清单
适用范围:基于 `phase-ts-0` ~ `phase-ts-5` 整理,最终交给 Codex 执行
---
## 使用说明
- 完成前使用 `- [ ]`
- 完成后改成 `- [x]`
- 实施顺序:`TS-0 → TS-1 → TS-2 → TS-3 → TS-4 → TS-5`
- 不建议跳阶段
---
## Phase TS-0当前现状与目标
- [x] 盘点 `useSidebarPlan.ts` 中真实数据与 mock 数据边界
- [x] 盘点 `KanbanPanel.vue` 的硬编码四象限
- [x] 盘点 `KanbanDetail.vue` 的 mock task / subtasks / comments / history
- [x] 盘点 `backend/app/models/task.py` 缺失字段
- [x] 输出业务层 / 聚合层 / commander 执行层三层架构说明
---
## Phase TS-1业务任务模型扩展
- [x] 扩展 `Task` 模型字段
- [x] 新增业务级 `TaskSubTask` 模型
- [x] 扩展 `TaskHistory` 动作类型
- [x] 编写对应 migration
- [x] 更新 `backend/app/schemas/task.py`
---
## Phase TS-2Task API 与 Schedule 聚合扩展
- [x] 扩展 `/api/tasks` create / update / list
- [x] 新增 `GET /api/tasks/{task_id}`
- [x] 新增 subtasks CRUD / reorder API
- [x] 新增 dispatch API
- [x] 扩展 `schedule-center/date` 返回 `focus_tasks`
- [x] 扩展 `schedule-center/date` 返回 `quadrants`
- [x] 扩展 `schedule-center/date` 返回 `commander_summary`
- [x] 更新 `frontend/src/api/task.ts`
- [x] 更新 `frontend/src/api/scheduleCenter.ts`
---
## Phase TS-3Chat 首页 Today Status 接真实数据
- [x] 删除 `useSidebarPlan.ts``mockFocusItems`
- [x]`sidebarFocusItems` 接真实 `focus_tasks`
- [x] 新增 `todayStatusQuadrants`
- [x] 改造 `KanbanPanel.vue` 为真实展示组件
- [x] 在 chat 首页接入真实象限与刷新逻辑
---
## Phase TS-4手动创建与详情编辑器
- [x] 改造 `KanbanDetail.vue` 为 create / edit 真实面板
- [x] 打通任务详情读取与保存
- [x] 打通子任务增删改排序
- [x] 打通分配字段编辑
- [x] 扩展 Schedule Center 的 `addTask()`
- [x] 支持象限内快捷新建
---
## Phase TS-5Commander 派发闭环
- [x] 新增 task dispatch API
- [x] 新增 subtask dispatch API
- [x] 建立业务 task -> commander payload 映射
- [x] commander 结果回写业务 task
- [x] 在 Today Status 展示 commander summary
- [x] 在详情页展示 dispatch 状态
---
## 验证清单
- [x] Chat 中 `/task` 可创建任务
- [x] Chat 中 `/task@commander` 可创建并派发任务
- [x] Schedule Center 手动创建任务后Today Status 同步更新
- [x] Today Status 中创建任务后Schedule Center 同步可见
- [x] 子任务刷新后仍保持一致
- [x] commander 状态可从 `queued -> running -> completed/failed` 更新
- [x] 回归确认现有 `todo / task / reminder / goal` 主路径不受影响

View File

@@ -0,0 +1,144 @@
# Phase TS-0当前现状与目标
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
先把当前代码里的真实能力、mock 能力、缺口和目标架构写清楚,避免后续实现直接在错误抽象上继续堆功能。
本阶段不改业务代码,重点是:
- 盘点真实数据流
- 盘点 mock 边界
- 明确业务 task 与 commander runtime task 的边界
- 输出目标三层架构
---
## 2. 当前代码现状
### 2.1 Chat 首页 Today Status
当前文件:
- `frontend/src/pages/chat/composables/useSidebarPlan.ts`
- `frontend/src/pages/chat/index.vue`
已真实存在的能力:
- `useSidebarPlan.ts` 已通过 `scheduleCenterApi.date()``scheduleCenterApi.month()` 加载真实数据
- `todayPlanCounters` 已基于真实 `todos / tasks / reminders / goals` 聚合统计
- Chat 首页已能打开 Today Status 抽屉
当前缺口:
- `sidebarFocusItems` 仍强制返回 `mockFocusItems`
- Today Status 的重点区、四象限、详情编辑并未接真实任务模型
### 2.2 Kanban UI
当前文件:
- `frontend/src/components/chat/KanbanPanel.vue`
- `frontend/src/components/chat/KanbanDetail.vue`
当前状态:
- `KanbanPanel.vue` 的四象限任务列表完全硬编码
- `KanbanDetail.vue` 的 task / subtasks / comments / history / assignee 都是本地 mock
- 子任务拖拽、完成状态、评论等均未持久化
### 2.3 业务任务模型
当前文件:
- `backend/app/models/task.py`
- `backend/app/routers/task.py`
- `frontend/src/api/task.ts`
当前状态:
- 只有基础 `Task`
- 只有简单 CRUD
- 没有业务级 `SubTask`
- 没有 `assignee` / `quadrant` / `conversation_id` / `dispatch_status`
### 2.4 Schedule Center
当前文件:
- `backend/app/routers/schedule_center.py`
- `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts`
当前状态:
- 已经是当前最真实的日程聚合入口
- 已支持 todo / task / reminder / goal 的真实新增与刷新
-`date` 聚合结果还不够直接支撑 Today Status 四象限与 commander 状态
### 2.5 Commander / Agent runtime
当前文件:
- `backend/app/agents/schemas/task.py`
- `backend/app/routers/agent.py`
当前状态:
- runtime 中已有 `owner_agent_id / parent_task_id / child_task_ids` 等执行态字段
- 更适合做执行态拓扑和可视化
- 不适合直接作为业务 Task / SubTask 主模型
---
## 3. 目标架构
建议采用三层结构:
### 3.1 业务任务层
面向用户的长期任务实体:
- Task
- TaskSubTask
- assignee
- quadrant
- source
- conversation_id
- dispatch 状态
### 3.2 调度聚合层
通过 `schedule-center` 提供:
- 今日统计
- focus tasks
- quadrants
- commander summary
### 3.3 执行层
保留现有 commander / runtime
- 接收业务任务派发
- 内部做 task graph 执行
- 把状态回写业务 task/subtask
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `frontend/src/pages/chat/composables/useSidebarPlan.ts` | 盘点 | 真实统计 + mock focus 边界 |
| `frontend/src/components/chat/KanbanPanel.vue` | 盘点 | 四象限 mock |
| `frontend/src/components/chat/KanbanDetail.vue` | 盘点 | 详情页 mock |
| `backend/app/models/task.py` | 盘点 | Task 字段缺口 |
| `backend/app/routers/task.py` | 盘点 | 仅简单 CRUD |
| `backend/app/routers/schedule_center.py` | 盘点 | 聚合能力现状 |
---
## 5. 验收标准
- [ ] 明确 Today Status 中哪些数据是真实的
- [ ] 明确哪些能力仍是 mock
- [ ] 明确业务 task 与 runtime task 的边界
- [ ] 给出业务层 / 聚合层 / 执行层三层架构
---
## 6. 依赖关系
```text
本阶段 → Phase TS-1
→ Phase TS-2
→ Phase TS-3
```
本阶段是后续所有实现文档的共识基础。

View File

@@ -0,0 +1,111 @@
# Phase TS-1业务任务模型扩展
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
补齐业务级 Task / SubTask / 分配 / 派发字段,为 Today Status、Schedule Center、Chat 创建、Commander 派发提供统一的数据主模型。
本阶段重点是“把业务模型补齐”,而不是直接接 commander runtime。
---
## 2. 详细任务
### 2.1 扩展 Task 模型
**文件**
- `backend/app/models/task.py`
- `backend/app/schemas/task.py`
建议新增字段:
- `source`
- `conversation_id`
- `quadrant`
- `assignee_type`
- `assignee_id`
- `dispatch_status`
- `dispatch_run_id`
- `result_summary`
- `started_at`
- `last_synced_at`
### 2.2 新增业务级 TaskSubTask
**文件**
- 新增 TaskSubTask 模型文件或在任务模型模块中补充
- `backend/app/schemas/task.py`
建议字段:
- `task_id`
- `title`
- `description`
- `status`
- `order_index`
- `assignee_type`
- `assignee_id`
- `dispatch_status`
- `dispatch_run_id`
- `completed_at`
### 2.3 扩展 TaskHistory 语义
建议新增 action
- `created_from_chat`
- `assigned`
- `subtask_created`
- `subtask_reordered`
- `dispatched_to_commander`
- `dispatch_status_changed`
### 2.4 设计 migration
需要新增 migration确保
- 旧 task 可兼容新字段
- 新 subtasks 表可按 task_id 关联
- 必要索引可支撑按日期 / 象限 / assignee / dispatch 状态查询
---
## 3. 设计原则
1. **业务 task 与 runtime task 分层**
- 不能直接把 runtime 的 `owner_agent_id / parent_task_id` 作为业务主模型。
2. **SubTask 必须是业务实体**
- 不能继续停留在前端本地数组。
3. **先支持显式字段,再做自动推导**
- `quadrant``assignee_type` 等优先用显式字段,不做复杂推断。
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `backend/app/models/task.py` | 修改 | 扩展 Task 字段 |
| `backend/app/schemas/task.py` | 修改 | 扩展 TaskCreate/TaskUpdate/TaskOut/SubTask schema |
| migration 文件 | 新增 | 数据库结构迁移 |
---
## 5. 验收标准
- [ ] Task 可表达来源、象限、分配对象、派发状态
- [ ] 有独立的业务级 SubTask 模型
- [ ] TaskHistory 能记录关键业务动作
- [ ] migration 方案清晰且可兼容旧数据
---
## 6. 依赖关系
```text
本阶段 → Phase TS-2API
→ Phase TS-4详情编辑器
→ Phase TS-5Commander 派发)
```
本阶段是后续所有真实任务操作的基础。

View File

@@ -0,0 +1,106 @@
# Phase TS-2Task API 与 Schedule 聚合扩展
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
扩展后端 Task API 与 Schedule Center 聚合,让前端可以真实创建、读取、编辑、分配任务,并让 Today Status 直接消费真实聚合视图。
---
## 2. 详细任务
### 2.1 扩展 Task API
**文件**
- `backend/app/routers/task.py`
- `backend/app/schemas/task.py`
- `frontend/src/api/task.ts`
建议接口:
- `GET /api/tasks`
- `POST /api/tasks`
- `GET /api/tasks/{task_id}`
- `PATCH /api/tasks/{task_id}`
- `DELETE /api/tasks/{task_id}`
- `POST /api/tasks/{task_id}/subtasks`
- `PATCH /api/tasks/{task_id}/subtasks/{subtask_id}`
- `DELETE /api/tasks/{task_id}/subtasks/{subtask_id}`
- `POST /api/tasks/{task_id}/subtasks/reorder`
- `POST /api/tasks/{task_id}/dispatch`
- `POST /api/tasks/{task_id}/subtasks/{subtask_id}/dispatch`
### 2.2 Task detail 输出
`GET /api/tasks/{task_id}` 建议返回:
- task 基础字段
- subtasks
- history
- dispatch 摘要
### 2.3 扩展 Schedule Center 聚合
**文件**
- `backend/app/routers/schedule_center.py`
- `backend/app/schemas/schedule_center.py`
- `frontend/src/api/scheduleCenter.ts`
建议在 `ScheduleCenterDateOut` 增加:
- `focus_tasks`
- `quadrants`
- `commander_summary`
### 2.4 前端 API 同步
`frontend/src/api/task.ts` 需要扩展:
- detail 方法
- subtask CRUD / reorder
- dispatch 方法
`frontend/src/api/scheduleCenter.ts` 需要扩展:
- `focus_tasks`
- `quadrants`
- `commander_summary` 类型
---
## 3. 设计原则
1. **复用现有 task router不另开 taskV2**
2. **复用现有 schedule-center 聚合,不另开第二套 today-status API**
3. **dispatch 是显式动作,不隐含在普通 update 里**
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `backend/app/routers/task.py` | 修改 | 扩展任务、子任务、派发 API |
| `backend/app/schemas/task.py` | 修改 | detail/subtask/dispatch schema |
| `backend/app/routers/schedule_center.py` | 修改 | 扩展今日聚合 |
| `backend/app/schemas/schedule_center.py` | 修改 | 扩展 response model |
| `frontend/src/api/task.ts` | 修改 | 前端任务 API 能力补齐 |
| `frontend/src/api/scheduleCenter.ts` | 修改 | 前端聚合类型补齐 |
---
## 5. 验收标准
- [ ] Task detail 能返回 subtasks / history / dispatch 信息
- [ ] 子任务 CRUD / reorder API 设计明确
- [ ] dispatch API 明确独立
- [ ] `schedule-center/date` 可直接供 Today Status 使用
- [ ] 前端 API 类型已同步
---
## 6. 依赖关系
```text
依赖Phase TS-1
输出给Phase TS-3 / TS-4 / TS-5
```

View File

@@ -0,0 +1,86 @@
# Phase TS-3Chat 首页 Today Status 接真实数据
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
把 Chat 首页 Today Status 从“真实统计 + mock 内容”升级为完整真实视图,让 Today Status 与 Schedule Center 使用同一份聚合真源。
---
## 2. 详细任务
### 2.1 useSidebarPlan 去 mock
**文件**
- `frontend/src/pages/chat/composables/useSidebarPlan.ts`
需要完成:
- 删除 `mockFocusItems`
- `sidebarFocusItems` 改为基于真实 `focus_tasks`
- 新增 `todayStatusQuadrants`
- 新增 `commanderSummary`
- 保留当前 `todayPlanCounters` 的真实聚合逻辑
### 2.2 KanbanPanel 真实化
**文件**
- `frontend/src/components/chat/KanbanPanel.vue`
需要完成:
- 去掉硬编码四象限数据
- 改为接收真实 `quadrants`
- emit 真实事件:
- `create-task`
- `open-task`
- `close`
- 页脚统计改为真实任务统计
### 2.3 chat 首页联动
**文件**
- `frontend/src/pages/chat/index.vue`
需要完成:
- Today Status 打开后加载真实 Kanban
- 任务创建 / 编辑 / 派发后触发 `loadSidebarPlanSnapshot()`
- 保持当前抽屉体验,不额外再造入口
---
## 3. 设计原则
1. **Today Status 与 Schedule Center 同源**
2. **KanbanPanel 做纯展示,不承载业务状态真源**
3. **以刷新真实聚合为准,不保留本地 fake 数据**
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `frontend/src/pages/chat/composables/useSidebarPlan.ts` | 修改 | 删除 focus mock接真实聚合 |
| `frontend/src/components/chat/KanbanPanel.vue` | 修改 | 改成真实展示组件 |
| `frontend/src/pages/chat/index.vue` | 修改 | 连接 Today Status、Kanban、刷新逻辑 |
---
## 5. 验收标准
- [ ] Today Status 重点列表不再使用 mock
- [ ] Kanban 四象限显示真实任务
- [ ] 统计、重点、象限来自同一份聚合结果
- [ ] Chat 首页可以稳定刷新任务状态
---
## 6. 依赖关系
```text
依赖Phase TS-2
输出给Phase TS-4 / TS-5
```

View File

@@ -0,0 +1,99 @@
# Phase TS-4手动创建与详情编辑器
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
补齐“手动创建任务”和“详情编辑”链路,让用户可以在 Today Status 与 Schedule Center 中真实创建、编辑任务与子任务。
---
## 2. 详细任务
### 2.1 改造 KanbanDetail 为真实编辑器
**文件**
- `frontend/src/components/chat/KanbanDetail.vue`
需要完成:
- 支持 `create | edit`
- 接 task detail API
- 编辑字段:
- `title`
- `description`
- `status`
- `priority`
- `quadrant`
- `assignee_type`
- `assignee_id`
- 子任务增删改排序接真实 API
- 历史接 `TaskHistory`
- 评论如果后端暂无支持,本阶段不强做真实化
### 2.2 Schedule Center 手动创建增强
**文件**
- `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts`
- `frontend/src/pages/schedule-center/index.vue`
需要完成:
- `addTask()` 支持:
- `quadrant`
- `description`
- `assignee_type`
- `assignee_id`
- `dispatch_to_commander`
- 保持 `loadDateDetail()` + `loadMonth()` 刷新闭环
### 2.3 象限内快捷新建
**文件**
- `frontend/src/components/chat/KanbanPanel.vue`
- `frontend/src/components/chat/KanbanDetail.vue`
- `frontend/src/pages/chat/index.vue`
需要完成:
- 点击象限 `+` 打开 `KanbanDetail(create)`
- 自动预填 `quadrant`
- 保存后刷新 Today Status
---
## 3. 设计原则
1. **KanbanDetail 是真实任务编辑器,不再保留 mock 状态真源**
2. **Schedule Center 是最完整的手动创建页面**
3. **Today Status 提供快捷创建,不与 Schedule Center 竞争真源**
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `frontend/src/components/chat/KanbanDetail.vue` | 修改 | 真实 create/edit 详情面板 |
| `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts` | 修改 | 手动创建增强 |
| `frontend/src/pages/schedule-center/index.vue` | 修改 | 表单与详情联动 |
| `frontend/src/pages/chat/index.vue` | 修改 | Today Status 快捷创建联动 |
---
## 5. 验收标准
- [ ] 用户可从 Today Status 手动创建任务
- [ ] 用户可从 Schedule Center 手动创建任务
- [ ] 用户可编辑任务、子任务、分配信息
- [ ] 刷新后数据保持一致
---
## 6. 依赖关系
```text
依赖Phase TS-2
建议在Phase TS-3 后整合
输出给Phase TS-5
```

View File

@@ -0,0 +1,99 @@
# Phase TS-5Commander 派发闭环
日期2026-04-08
状态:待实施
---
## 1. 阶段目标
把“交给指挥官执行”从 UI 字段变成真实执行链路并把执行状态回写到业务任务、Today Status 与 Schedule Center。
---
## 2. 详细任务
### 2.1 新增 dispatch API
**文件**
- `backend/app/routers/task.py`
- `backend/app/schemas/task.py`
- `frontend/src/api/task.ts`
建议接口:
- `POST /api/tasks/{id}/dispatch`
- `POST /api/tasks/{id}/subtasks/{subtask_id}/dispatch`
### 2.2 业务 task -> commander payload 映射
需要定义 payload建议至少包含
- `business_task_id`
- `title`
- `description`
- `subtasks`
- `priority`
- `due_date`
- `conversation_id`
- `user_id`
### 2.3 commander 执行态回写
需要回写业务字段:
- `dispatch_status`
- `dispatch_run_id`
- `result_summary`
- 必要时同步 task / subtask 状态
### 2.4 前端状态展示
**文件**
- `frontend/src/components/chat/KanbanDetail.vue`
- `frontend/src/pages/chat/composables/useSidebarPlan.ts`
- `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts`
需要完成:
- 详情页显示 `queued / running / completed / failed`
- Today Status 聚合显示 `commander_summary`
- Schedule Center detail 可见调度状态
---
## 3. 设计原则
1. **assignee 与 dispatch 分离**
- `assignee_type=commander` 不等于已经派发执行。
2. **业务层与 runtime 层分离**
- runtime 负责执行,业务 task 负责长期状态。
3. **Today Status 与 Schedule Center 状态一致**
- 不允许首页和调度页看到不同状态。
---
## 4. 核心文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `backend/app/routers/task.py` | 修改 | 新增 dispatch API |
| commander / orchestration service | 修改 | 业务 task 派发到执行层 |
| `frontend/src/api/task.ts` | 修改 | dispatch API 封装 |
| `frontend/src/components/chat/KanbanDetail.vue` | 修改 | 派发入口与状态展示 |
| `frontend/src/pages/chat/composables/useSidebarPlan.ts` | 修改 | commander summary 展示 |
| `frontend/src/pages/schedule-center/composables/useScheduleCenterPage.ts` | 修改 | 调度状态联动 |
---
## 5. 验收标准
- [ ] 任务可派发给 commander
- [ ] 子任务可派发给 commander
- [ ] Today Status 能看到 commander 状态变化
- [ ] Schedule Center 与 Chat 首页状态一致
- [ ] 业务模型与 runtime 模型保持分层
---
## 6. 依赖关系
```text
依赖Phase TS-1 / TS-2 / TS-4
```

View File

@@ -0,0 +1,90 @@
# War Room 实施计划索引
本目录用于沉淀 `War Room` 页面的分阶段实施计划。目标不是重写一份设计稿,而是把已经批准的规格文档与仓库当前实现状态对齐,形成一套可直接执行、可验证、可拆分的交付路线。
规格来源:
- `docs/superpowers/specs/2026-04-09-war-room-design.md`
当前仓库锚点:
- `frontend/src/pages/war-room/index.vue`
- `frontend/src/app/router/routes.ts`
- `frontend/src/pages/agents/`
- `frontend/src/pages/chat/`
- `frontend/src/api/`
- `backend/app/routers/`
## 文档说明
| 文件 | 说明 |
|------|------|
| `README.md` | 总览、阶段关系、设计原则、风险与推荐顺序 |
| `phase-wr-0-current-state.md` | 当前实现盘点、差距分析、目标边界 |
| `phase-wr-1-shell-and-fixed-mode.md` | 页面骨架、组件拆分、FIXED 模式落地 |
| `phase-wr-2-studio-foundation.md` | STUDIO 模式画布、节点交互、本地编排状态 |
| `phase-wr-3-data-contract-and-api.md` | 前后端数据契约、API、状态管理与 mock 替换 |
| `phase-wr-4-teams-runtime-chat-handoff.md` | TEAMS、Runtime、/chat 执行链与联调收口 |
| `checklist.md` | 供 Codex 或人工执行的勾选清单 |
## 推荐阅读顺序
1. `phase-wr-0-current-state.md`
2. `phase-wr-1-shell-and-fixed-mode.md`
3. `phase-wr-2-studio-foundation.md`
4. `phase-wr-3-data-contract-and-api.md`
5. `phase-wr-4-teams-runtime-chat-handoff.md`
6. `checklist.md`
## 当前总体状态2026-04-10
| Phase | 状态 | 说明 |
|------|------|------|
| WR-0 | 已完成 | 已完成现状盘点与目标拆分 |
| WR-1 | 待开始 | 先把单文件样机收敛成可扩展页面骨架 |
| WR-2 | 待开始 | 补 STUDIO 的真实交互和画布状态 |
| WR-3 | 待开始 | 接入真实数据契约与 API |
| WR-4 | 待开始 | 完成 TEAMS、Runtime 与 /chat 执行闭环 |
## 当前实现结论
1. `/war-room` 页面已经存在,但本质上仍是静态样机。
2. 主要数据来自 `index.vue` 内部硬编码数组,尚未接任何 `war-room` 专用 API。
3. 页面目前额外存在 `runs` 模式,这与已批准 spec 的三模式 `FIXED | STUDIO | TEAMS` 不完全一致。
4. 当前文件存在中文文案乱码,说明在编码或文案维护链路上已有质量问题。
5. 页面代码集中在单个 SFC 中,后续接 API、拖拽和 Inspector 交互时维护成本会迅速放大。
## 总体实施原则
1. 先拆骨架再接能力,避免在超大单文件上继续叠加逻辑。
2. 先保留现有视觉语言,再把 mock 数据逐步替换成真实状态。
3. 先建立最小可运行的本地编排模型,再接后端编排 API。
4. `/war-room``/agents` 保持风格连续,但不强行共用不稳定实现。
5. `/chat` 执行链路必须在计划中尽早定接口,否则 `运行` 按钮只能长期停留在占位态。
6. 每一阶段都要求有明确验证物,而不是只交页面视觉变化。
## 阶段依赖图
```text
WR-0 -> WR-1 -> WR-2 -> WR-3 -> WR-4
```
不建议跳阶段。尤其是:
- 没有完成 WR-1 的组件化拆分WR-2 的画布交互会持续堆积技术债。
- 没有完成 WR-3 的数据契约WR-4 的运行态和 `/chat` 交接无法闭环。
## 预估工作量
| Phase | 预估 |
|------|------|
| WR-1 | 1.5 天 |
| WR-2 | 2 天 |
| WR-3 | 2 天 |
| WR-4 | 1.5 天 |
| 总计 | 7 天 |
## 关键风险
1. 现有 `war-room` 样机和批准 spec 在模式定义上已有偏差,实施前需要以 spec 为准清理页面信息架构。
2. STUDIO 模式若引入拖拽库或画布库,会触发“无新依赖”的约束;默认应优先使用现有能力或原生方案。
3. `GET /api/agents/templates` 与现有 `agent.py` 路由职责边界需要先定,避免后端语义混乱。
4. `/chat` 如何接受编排执行上下文目前仍是待确认项,必须在 WR-3 结束前明确。

View File

@@ -0,0 +1,59 @@
# War Room 执行清单
## WR-0 现状梳理
- [x] 阅读批准规格文档
- [x] 盘点现有 `/war-room` 页面
- [x] 识别与 spec 的主要偏差
- [x]`development-doc/plan/war-room-update/` 落盘计划文档
## WR-1 页面骨架与 FIXED
- [ ] 移除 `RUNS` 一级模式或降为非一级入口
- [ ] 拆分 `index.vue`
- [ ] 新建 `useWarRoomPage.ts`
- [ ] 新建 `ModeStrip`
- [ ] 新建 `ResourceBay`
- [ ] 新建 `InspectorBay`
- [ ] 新建 `RuntimeStrip`
- [ ] 新建 `StageFixed`
- [ ] 修复中文乱码
- [ ] 完成 FIXED 选中与 Inspector 联动
- [ ] 完成模板实例化为本地 orchestration 草稿
## WR-2 STUDIO
- [ ] 定义 `FlowNode` / `FlowEdge` / orchestration draft 类型
- [ ] 新建 `NodePalette`
- [ ] 新建 `FlowCanvas`
- [ ] 支持节点新增
- [ ] 支持节点选择
- [ ] 支持节点移动
- [ ] 支持节点删除
- [ ] 支持连线创建
- [ ] 支持 Inspector 配置联动
- [ ] 打通 FIXED -> STUDIO
## WR-3 数据与 API
- [ ] 新建 `frontend/src/api/warRoom.ts`
- [ ] 设计 Agent Templates API
- [ ] 设计 Orchestrations API
- [ ] 设计 Teams API
- [ ] 新增后端 router / schema / service
- [ ] 替换首页 mock 数据
- [ ] 增加加载态 / 空态 / 错误态
- [ ] 补充前后端测试
## WR-4 闭环与联调
- [ ] 实现 `StageTeams`
- [ ] 实现 TeamNetwork
- [ ] Runtime feed 接真实数据
- [ ] 设计并实现 launch 接口
- [ ] 打通 `/war-room` -> `/chat`
- [ ] 清理无效按钮和占位行为
- [ ] 完成响应式回归
- [ ] 运行前端测试
- [ ] 运行后端测试
- [ ] 手测完整主链路

View File

@@ -0,0 +1,111 @@
# WR-0 当前状态与目标边界
## 1. 现状盘点
### 1.1 已有能力
1. 前端已有路由 `frontend/src/app/router/routes.ts -> /war-room`
2. 页面主体已存在于 `frontend/src/pages/war-room/index.vue`
3. 页面已有较完整的科幻视觉语言:
- 顶部指挥栏
- 左侧资源面板
- 中央主舞台
- 右侧 Inspector
- 底部 Runtime Strip
4. 页面已经有四组静态展示数据:
- `fixedOps`
- `studioNodes`
- `teams`
- `runFeed`
### 1.2 当前缺口
1. 页面仍是单文件实现,结构、状态、样式全部耦合在一个 SFC 内。
2. 当前数据全部 hardcoded没有 Pinia store、没有 composable、没有 API 层。
3. Inspector 并不真正跟随画布选中项变化。
4. STUDIO 只是示意图,没有拖拽、连线、缩放、平移、删除等 spec 级交互。
5. TEAMS 模式只有静态卡片,没有成员拓扑、协作协议详情、操作入口。
6. Runtime feed 只有静态列表,不反映真实运行记录。
7. 顶部按钮没有闭环:
- `NEW AGENT`
- `LAUNCH FLOW`
8. 文案中存在乱码,说明页面内容还未进入可交付质量线。
9. 现有页面多出 `RUNS` 模式,与批准 spec 不一致。
## 2. 设计与实现的差距
| 维度 | Spec 要求 | 当前状态 | 结论 |
|------|-----------|----------|------|
| 模式数量 | FIXED / STUDIO / TEAMS | fixed / studio / teams / runs | 需要收敛模式定义 |
| 数据来源 | API + 可持续维护的数据结构 | 本地 mock | 必须抽离 |
| 组件结构 | WarRoomPage + 子组件 | 单文件 | 必须拆分 |
| FIXED | 模板浏览与实例化 | 静态卡片 | 可复用现有视觉,但要接真实数据 |
| STUDIO | 可编排画布 | 仅示意图 | 需要实做交互层 |
| TEAMS | 群组协作视图 | 静态卡片 | 需要补数据与拓扑 |
| Runtime | 实时事件滚动 | 静态假数据 | 需要接执行记录 |
| /chat handoff | 可运行当前编排 | 未落地 | 必须定义接口 |
## 3. 目标边界
本轮计划的目标是把 `/war-room` 做成“可维护、可接真实数据、可触发执行”的页面,而不是一步做完所有高级编排能力。
本轮完成标准:
1. 页面结构完成组件化和状态分层。
2. FIXED / STUDIO / TEAMS 三模式与 spec 对齐。
3. 至少一条最小编排链路可以保存并交给 `/chat` 执行。
4. Inspector、Runtime、资源面板不再是纯静态展示。
5. 现有乱码文案清理完成。
本轮不强求:
1. 完整 BPMN 级流程设计器。
2. 真正的多人实时协同编辑。
3. 复杂权限系统。
4. 高级运行回放或可视化 tracing。
## 4. 建议目标架构
### 4.1 前端
建议拆分为:
- `frontend/src/pages/war-room/index.vue`
- `frontend/src/pages/war-room/composables/useWarRoomPage.ts`
- `frontend/src/pages/war-room/components/ModeStrip.vue`
- `frontend/src/pages/war-room/components/ResourceBay.vue`
- `frontend/src/pages/war-room/components/InspectorBay.vue`
- `frontend/src/pages/war-room/components/RuntimeStrip.vue`
- `frontend/src/pages/war-room/components/stage-fixed/*`
- `frontend/src/pages/war-room/components/stage-studio/*`
- `frontend/src/pages/war-room/components/stage-teams/*`
- `frontend/src/api/warRoom.ts`
### 4.2 后端
建议按职责拆分:
- `backend/app/routers/agent.py`
- 扩展模板读取接口,或显式挂载模板子路由
- `backend/app/routers/orchestration.py`
- `backend/app/routers/team.py`
- `backend/app/schemas/war_room.py`
- `backend/app/services/war_room/`
### 4.3 状态
建议建立统一页面状态:
- 当前模式
- 资源列表
- 当前选中项
- 当前编排图
- 团队列表
- 运行态 feed
- 加载与错误状态
## 5. 前置决策
1. 以批准 spec 为准,移除或降级当前 `RUNS` 模式。
2. 先不引入新依赖STUDIO 默认使用原生事件 + SVG 方案。
3. Agent templates 与 orchestrations 的 API 职责分离,不把所有 war-room 数据都塞进一个接口。
4. `/chat` 执行链先走最小方案:
- 选择当前 orchestration
- 生成执行 payload
- 跳转 `/chat` 并带上 orchestration id 或 execution context

View File

@@ -0,0 +1,82 @@
# WR-1 页面骨架与 FIXED 模式
## 目标
把当前 `frontend/src/pages/war-room/index.vue` 从静态单文件样机收敛成可扩展页面骨架,并完成 FIXED 模式的真实结构化落地。
## 范围
1. 页面组件化拆分。
2. 状态从本地散落常量迁移到 composable 或 store。
3. 统一模式定义,只保留 `fixed / studio / teams`
4. FIXED 模式完成模板浏览、选中、详情展示和“实例化到编排”入口。
5. 清理乱码文案。
## 具体任务
### 1. 拆页面骨架
建议拆分:
- `index.vue` 只保留页面装配
- `ModeStrip.vue`
- `ResourceBay.vue`
- `InspectorBay.vue`
- `RuntimeStrip.vue`
- `StageFixed.vue`
### 2. 抽页面状态
新增 `useWarRoomPage.ts`,集中管理:
- `activeMode`
- `selectedResourceId`
- `selectedEntity`
- `templateList`
- `runtimeFeed`
- 页面级动作方法
### 3. FIXED 模式最小闭环
FIXED 要先支持:
1. 展示模板列表
2. 点击模板卡片后 Inspector 更新
3. `OPEN` 打开详情态
4. `INSTANTIATE` 将模板转成一份本地 orchestration 草稿
### 4. 样式收敛
1. 保留现有视觉方向,不做大规模重设计。
2. 把 stage 内部样式拆到子组件可维护范围。
3. 校正文案乱码,统一中英文策略。
## 建议文件变更
### Frontend
- 新增 `frontend/src/pages/war-room/composables/useWarRoomPage.ts`
- 新增 `frontend/src/pages/war-room/components/ModeStrip.vue`
- 新增 `frontend/src/pages/war-room/components/ResourceBay.vue`
- 新增 `frontend/src/pages/war-room/components/InspectorBay.vue`
- 新增 `frontend/src/pages/war-room/components/RuntimeStrip.vue`
- 新增 `frontend/src/pages/war-room/components/stage-fixed/StageFixed.vue`
- 视情况新增 `frontend/src/pages/war-room/types.ts`
- 重写 `frontend/src/pages/war-room/index.vue`
## 验收标准
1. `war-room` 页面不再依赖单个超大 SFC。
2. 页面模式与 spec 对齐,没有额外 `RUNS` 一级模式。
3. 选中 FIXED 卡片时Inspector 正确更新。
4. `INSTANTIATE` 能产生本地 orchestration 草稿状态。
5. 页面中文文案不再乱码。
## 验证建议
1. 前端单测:
- 模式切换
- FIXED 卡片选中
- instantiate 行为
2. 页面手测:
- `/war-room`
- 窄屏布局
- Inspector 切换

View File

@@ -0,0 +1,101 @@
# WR-2 STUDIO 模式基础能力
## 目标
把 STUDIO 从静态示意画布升级为“可编辑的最小编排画布”。
## 范围
1. NodePalette 节点面板
2. FlowCanvas 本地状态
3. 节点选中、拖拽、删除
4. SVG 连线
5. Inspector 与节点配置联动
## 具体任务
### 1. 节点模型落地
建立前端类型:
- `FlowNode`
- `FlowEdge`
- `FlowHandle`
- `OrchestrationDraft`
补充最小字段:
- `id`
- `type`
- `label`
- `role`
- `position`
- `config`
- `status`
### 2. NodePalette
按 spec 至少支持:
- Trigger
- Agent
- Tool
- Condition
- Memory
第一版可先做“点击添加到画布”,第二版再补完整拖拽源体验。
### 3. 画布交互
最小要求:
1. 新增节点
2. 选择节点
3. 移动节点
4. 删除节点
5. 建立连接
6. 画布缩放和平移
### 4. Inspector 联动
选中节点后展示:
- 节点类型
- 角色/标签
- 配置摘要
- 删除/复制/断开操作
### 5. FIXED 到 STUDIO 的桥接
WR-1 里 `INSTANTIATE` 生成的草稿,必须能在 STUDIO 中打开并编辑。
## 建议文件变更
### Frontend
- 新增 `frontend/src/pages/war-room/components/stage-studio/StageStudio.vue`
- 新增 `frontend/src/pages/war-room/components/stage-studio/NodePalette.vue`
- 新增 `frontend/src/pages/war-room/components/stage-studio/FlowCanvas.vue`
- 新增 `frontend/src/pages/war-room/components/stage-studio/StudioNode.vue`
- 新增 `frontend/src/pages/war-room/components/stage-studio/FlowEdges.vue`
- 扩展 `useWarRoomPage.ts`
## 验收标准
1. STUDIO 中至少能完成一个最小链路:
- Trigger -> Agent -> Condition -> Output
2. Inspector 会随着选中节点切换。
3. 删除节点后相关连线同步清理。
4. 从 FIXED 实例化进入 STUDIO 不丢节点数据。
## 风险与约束
1. 默认不引入新依赖,画布能力优先原生实现。
2. 若原生方案复杂度失控,再单独评估依赖引入,不在本阶段默认实施。
## 验证建议
1. 交互测试:
- 节点新增
- 节点删除
- 连线创建
- Inspector 更新
2. 手测:
- 缩放和平移
- 复杂布局下的线条表现

View File

@@ -0,0 +1,120 @@
# WR-3 数据契约与 API 集成
## 目标
`war-room` 从本地草稿和 mock 数据过渡到真实数据契约,完成前后端 API、页面状态和测试的基础闭环。
## 范围
1. 设计并实现模板、编排、群组三组接口。
2. 建立前端 `warRoom.ts` API 层。
3. 接入 Pinia 或 composable 的异步数据流。
4. 去除页面核心 mock 数据。
## API 规划
### 1. Agent Templates
优先对齐 spec
- `GET /api/agents/templates`
- `GET /api/agents/templates/:id`
说明:
- 可以挂在现有 `agent.py`,但只承载模板读取,不混入运行态编辑逻辑。
### 2. Orchestrations
建议新增路由:
- `GET /api/orchestrations`
- `POST /api/orchestrations`
- `PUT /api/orchestrations/:id`
- `DELETE /api/orchestrations/:id`
### 3. Teams
建议新增路由:
- `GET /api/teams`
- `POST /api/teams`
- `PUT /api/teams/:id`
- `DELETE /api/teams/:id`
## 数据结构建议
### Frontend / Backend 共识
```ts
interface AgentTemplate {
id: string
name: string
type: 'schedule' | 'knowledge' | 'bookkeeping' | 'dispatch'
kicker: string
icon: string
tone: 'cyan' | 'amber' | 'green' | 'violet'
summary: string
stats: string[]
flow: string[]
}
```
```ts
interface Orchestration {
id: string
name: string
nodes: FlowNode[]
edges: FlowEdge[]
source_template_id?: string | null
updated_at: string
}
```
```ts
interface TeamSummary {
id: string
name: string
protocol: 'leader-led' | 'pipeline' | 'roundtable'
member_count: number
focus: string
}
```
## 建议文件变更
### Backend
- 新增 `backend/app/routers/orchestration.py`
- 新增 `backend/app/routers/team.py`
- 新增 `backend/app/schemas/war_room.py`
- 视情况新增 `backend/app/services/war_room/`
- 修改 `backend/app/main.py` 注册路由
### Frontend
- 新增 `frontend/src/api/warRoom.ts`
- 扩展 `useWarRoomPage.ts`
- 让 FIXED / STUDIO / TEAMS 从 API 读数据
## 验收标准
1. `/war-room` 首屏不再依赖硬编码模板与团队数据。
2. orchestration 草稿可以创建、读取、更新。
3. TEAMS 模式能读取真实列表。
4. 数据加载、空态、错误态都可见。
## 测试要求
### Backend
- router 单测
- schema 序列化校验
- create/update/delete 流程测试
### Frontend
- API mock 测试
- 页面加载状态测试
- 保存 orchestration 行为测试
## 前置决策
WR-3 结束前必须明确 `/chat` 接口需要的最小执行 payload否则 WR-4 无法完成。

View File

@@ -0,0 +1,89 @@
# WR-4 TEAMS、Runtime 与 /chat 执行闭环
## 目标
完成 `war-room` 的最后一公里,让页面从“可编辑”升级为“可发起执行并观察结果”。
## 范围
1. TEAMS 模式真实化
2. Runtime strip 与运行记录联动
3. 顶部 `运行` 按钮接入 `/chat`
4. 页面整体联调和回归验证
## 具体任务
### 1. TEAMS 模式
补齐:
- TeamCard
- TeamNetwork
- 协作协议说明
- 选中群组后的 Inspector 详情
### 2. Runtime Feed
最小要求:
1. 读取最近执行记录
2. 显示时间、事件、详情、状态
3. 与当前 orchestration 或 team 建立过滤关系
### 3. /chat handoff
建议最小方案:
1. 用户在 `/war-room` 选择当前 orchestration
2. 点击 `运行`
3. 前端调用执行准备接口,返回 `execution_context`
4. 跳转 `/chat`
5. `/chat` 根据上下文自动加载对应执行任务或预填 prompt
候选接口:
- `POST /api/orchestrations/:id/launch`
返回建议:
- `orchestration_id`
- `execution_id`
- `chat_seed`
- `target_route`
### 4. 页面收口
1. 统一顶部按钮行为
2. 统一空态、错误态和加载态
3. 完成视觉细节与响应式收口
4. 去掉临时占位按钮和无效入口
## 建议文件变更
### Frontend
- 新增 `frontend/src/pages/war-room/components/stage-teams/StageTeams.vue`
- 新增 `frontend/src/pages/war-room/components/stage-teams/TeamCard.vue`
- 新增 `frontend/src/pages/war-room/components/stage-teams/TeamNetwork.vue`
- 扩展 `RuntimeStrip.vue`
- 修改 `/chat` 接收 war-room 上下文的入口逻辑
### Backend
- 扩展 orchestration launch 接口
- 增加运行记录查询接口或复用现有日志/任务体系
## 验收标准
1. TEAMS 模式展示真实群组数据。
2. Runtime feed 能展示真实运行记录或最小真实事件流。
3. 点击 `运行` 可以从 `/war-room` 跳转 `/chat` 并带上执行上下文。
4. 页面不存在主要占位假按钮。
## 验证建议
1. 后端接口测试:
- launch
- runtime list
2. 前端集成测试:
- launch 成功跳转
- runtime feed 刷新
3. 手测:
- FIXED -> STUDIO -> 保存 -> 运行 -> /chat
- TEAMS 选中与 Inspector 联动

View File

@@ -0,0 +1,372 @@
# War Room 智能体控制中心 - 设计规范
> 日期2026-04-09
> 状态:已批准
## 1. 概述
### 1.1 页面定位
**智能体控制中心** - JARVIS 的全局智能体管理界面,一目了然展示所有智能体,支持快速创建、编排、群组协作。
### 1.2 核心价值
- **可视化** - 所有智能体和流程一目了然
- **可编排** - 拖拽创建智能体工作流
- **可协作** - 多智能体群组分工配合
- **可扩展** - 用户可自定义节点和智能体
### 1.3 与 /agents 关系
- `/agents` 是**单会话级别**的智能体视图(当前会话的 Agent 拓扑)
- `/war-room` 是**全局级别**的智能体控制中心(全部 Agent 模板和编排)
- 两者风格统一war-room 可跳转去 /agents 编辑特定智能体
---
## 2. 技术栈
- **框架**Vue 3 + TypeScript
- **状态管理**Pinia
- **UI 库**Element Plus + Lucide Icons
- **动画**Vue Motion
- **样式**Scoped CSS + CSS Variables
---
## 3. 布局结构
### 3.1 整体布局(三栏式)
```
┌──────────────────────────────────────────────────────────────┐
│ WAR ROOM - 智能体控制中心 [+ 新建] [▶ 运行]│
├────────────┬───────────────────────────────┬─────────────────┤
│ │ │ │
│ 资源面板 │ 画布区域 │ 详情面板 │
│ MODES │ │ INSPECTOR │
│ │ [根据模式切换视图] │ │
│ • 固定模板 │ │ [选中项详情] │
│ • 编排画布 │ │ │
│ • 群组协作 │ │ [操作按钮] │
│ │ │ │
├────────────┴───────────────────────────────┴─────────────────┤
│ RUNTIME FEED - 实时状态滚动 │
└──────────────────────────────────────────────────────────────┘
```
### 3.2 顶部栏Top Bar
- 标题WAR ROOM - 智能体控制中心
- 右侧按钮:
- `+ 新建` - 新建智能体/编排/群组
- `▶ 运行` - 启动当前编排(跳转到 /chat 执行)
### 3.3 左侧资源面板Resource Bay
- **模式切换**FIXED | STUDIO | TEAMS
- **快速创建**
- CREATE AGENT
- CREATE FLOW
- CREATE TEAM
- **资源列表**(根据模式动态切换)
### 3.4 中央画布Command Stage
根据活跃模式显示不同内容
### 3.5 右侧详情面板Inspector Bay
显示选中项详情和操作按钮
### 3.6 底部运行时面板Runtime Strip
实时事件滚动
---
## 4. 模式详细设计
### 4.1 FIXED 模式 - 固定智能体模板
#### 4.1.1 模板矩阵2x2 卡片网格)
| 类型 | 图标 | 颜色 | 描述 |
|------|------|------|------|
| 时间管理 | CalendarClock | cyan | 读取日历、任务与提醒,输出全天日程 |
| 知识管理 | Brain | amber | 聚合知识库、上下文,生成检索与摘要 |
| 记账财务 | ReceiptText | green | 记录账目、消费分类、生成日报月报 |
| 任务分发 | CircuitBoard | violet | 待办路由到 Planner/Executor/Researcher |
#### 4.1.2 每个卡片内容
- **Kicker**类型标签TIME / MEMORY / FINANCE / COMMAND
- **标题**:智能体名称
- **摘要**:功能描述
- **统计**Agent数量 / 工具数量 / 特性标签
- **操作**OPEN查看详情| INSTANTIATE添加到编排画布
#### 4.1.3 流程示意 Lane
固定模板下方显示流程链:
```
TEMPLATE CORE → PLANNER → KNOWLEDGE → HUMAN GATE → OUTPUT
```
### 4.2 STUDIO 模式 - 拖拽编排画布
#### 4.2.1 节点类型
| 类型 | 说明 | 颜色 |
|------|------|------|
| Trigger | 触发器(定时/手动/文件)| cyan |
| Agent | 智能体节点 | amber |
| Tool | 工具节点 | green |
| Condition | 条件分支 | violet |
| Memory | 记忆节点 | cyan |
#### 4.2.2 节点面板(左侧,可折叠)
```
├── TRIGGERS
│ ├── ⏰ 定时触发
│ ├── 🎯 手动触发
│ └── 📁 文件触发
├── AGENTS
│ ├── 🤖 预置智能体(从固定模板加载)
│ └── 自定义智能体
├── TOOLS
│ ├── 📅 日历工具
│ ├── 💰 记账工具
│ └── 📚 知识库工具
├── CONDITIONS
│ ├── 🔀 条件分支
│ └── ⏳ 延时节点
└── MEMORY
├── 🧠 共享记忆
└── 📝 上下文
```
#### 4.2.3 画布交互
- **拖拽**:从面板拖拽节点到画布
- **连接**:点击节点边缘锚点,拖拽到另一节点建立连线
- **选择**:点击节点打开右侧 Inspector
- **删除**:选中节点按 Delete 或右键菜单
- **缩放**:滚轮缩放画布
- **平移**:空白区域拖拽平移
#### 4.2.4 节点样式(沿用 /agents 风格)
- 四角装饰线
- 状态指示灯active/idle/disabled
- 节点标签、名称、角色
- 描述文字
- 任务标签(当前执行中任务)
#### 4.2.5 SVG 连接线
- 贝塞尔曲线连接
- 虚线流动动画
- 高亮激活状态
### 4.3 TEAMS 模式 - 多智能体群组
#### 4.3.1 群组卡片
每个群组显示:
- **群组名称**:如 OPS-ALPHA、LEDGER-NET
- **协作协议**Leader-led / Pipeline / Roundtable
- **成员数**N members
- **专注领域**:时间与任务编排 / 账目录入与日报 / 知识聚合与检索
#### 4.3.2 成员拓扑图
群组卡片内显示成员节点网络:
```
LEADER
/ \
AGENT AGENT
\ /
GATE(人机确认)
```
#### 4.3.3 协作协议
| 协议 | 说明 |
|------|------|
| Leader-led | 一个领导 Agent 协调,其他执行 |
| Pipeline | 按顺序处理,上游输出下游输入 |
| Roundtable | 平等协作,投票或共识决策 |
---
## 5. 组件清单
### 5.1 WarRoomPage主页面
- 整体布局管理
- 模式状态
- 主题样式(沿用 /agents 科幻风格)
### 5.2 ModeStrip模式切换条
- 三个模式按钮FIXED | STUDIO | TEAMS
- 当前模式高亮
### 5.3 ResourceBay资源面板
- 快速创建按钮组
- 资源列表(动态)
- 底部统计
### 5.4 CommandStage中央画布
- 动态组件切换
- StageFixed / StageStudio / StageTeams
### 5.5 StageFixed固定模板视图
- OpsGrid2x2 卡片网格)
- OpsLane流程示意
### 5.6 StageStudio编排画布
- NodePalette节点面板
- FlowCanvas拖拽画布
- StudioNodes节点组件
- SVG 连接线
### 5.7 StageTeams群组视图
- TeamsGrid群组卡片网格
- TeamNetwork群组拓扑
### 5.8 InspectorBay详情面板
- 动态详情显示
- 操作按钮组
### 5.9 RuntimeStrip运行时面板
- 事件滚动列表
---
## 6. 数据结构
### 6.1 智能体模板
```typescript
interface AgentTemplate {
id: string
name: string
type: 'schedule' | 'knowledge' | 'bookkeeping' | 'dispatch'
kicker: string
icon: string
tone: 'cyan' | 'amber' | 'green' | 'violet'
summary: string
stats: string[]
flow: string[] // 流程节点
}
```
### 6.2 编排节点
```typescript
interface FlowNode {
id: string
type: 'trigger' | 'agent' | 'tool' | 'condition' | 'memory'
label: string
role: string
position: { x: number, y: number }
config: Record<string, any>
}
```
### 6.3 连接线
```typescript
interface FlowEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}
```
### 6.4 群组
```typescript
interface Team {
id: string
name: string
protocol: 'leader-led' | 'pipeline' | 'roundtable'
members: TeamMember[]
focus: string
}
```
---
## 7. 样式规范
### 7.1 色彩系统(沿用 /agents
```css
--accent-cyan: #00f5d4;
--accent-amber: #f9a825;
--accent-green: #00c853;
--accent-violet: #be9fff;
--bg-void: #050810;
--bg-card: #0d1525;
--border-mid: rgba(0, 245, 212, 0.15);
--text-primary: #e8ffff;
--text-secondary: #8fe4ff;
--text-dim: #4a6670;
```
### 7.2 字体
```css
--font-display: 'Orbitron', monospace;
--font-mono: 'JetBrains Mono', 'Share Tech Mono', monospace;
```
### 7.3 节点样式
- 四角装饰线10px 角落)
- 状态环动画active 时脉冲)
- 悬浮高亮效果
- 选中态边框发光
### 7.4 背景效果
- 网格背景40px 间隔)
- 扫描线效果
- 粒子漂浮动画
---
## 8. API 接口
### 8.1 智能体模板
- `GET /api/agents/templates` - 获取预置模板列表
- `GET /api/agents/templates/:id` - 获取模板详情
### 8.2 编排
- `GET /api/orchestrations` - 获取编排列表
- `POST /api/orchestrations` - 创建编排
- `PUT /api/orchestrations/:id` - 更新编排
- `DELETE /api/orchestrations/:id` - 删除编排
### 8.3 群组
- `GET /api/teams` - 获取群组列表
- `POST /api/teams` - 创建群组
- `PUT /api/teams/:id` - 更新群组
- `DELETE /api/teams/:id` - 删除群组
---
## 9. 实现优先级
### Phase 1 - 基础框架
1. WarRoomPage 主布局
2. ModeStrip 模式切换
3. ResourceBay 资源面板(基础)
4. RuntimeStrip 运行时面板(基础)
### Phase 2 - FIXED 模式
5. StageFixed 固定模板视图
6. OpsGrid 卡片组件
7. InspectorBay 详情面板
8. 模板数据 + API 集成
### Phase 3 - STUDIO 模式
9. NodePalette 节点面板
10. FlowCanvas 拖拽画布
11. SVG 连接线
12. 节点配置抽屉
13. 编排 API 集成
### Phase 4 - TEAMS 模式
14. StageTeams 群组视图
15. TeamCard 群组卡片
16. 群组 API 集成
---
## 10. 待确认事项
- [ ] 节点面板的具体节点列表
- [ ] 智能体模板的详细配置项
- [ ] 群组成员的角色定义
- [ ] 编排的执行逻辑(如何传递给 /chat 执行)
---
*本设计规范为 JARVIS War Room 页面的完整设计指南。*

View File

@@ -15,6 +15,7 @@
"element-plus": "^2.13.6",
"lucide-vue-next": "^0.577.0",
"motion": "^12.38.0",
"phaser": "^3.90.0",
"pinia": "^3.0.4",
"three": "^0.180.0",
"vue": "^3.5.30",
@@ -1963,6 +1964,12 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
@@ -3028,6 +3035,15 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/phaser": {
"version": "3.90.0",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz",
"integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -17,6 +17,7 @@
"element-plus": "^2.13.6",
"lucide-vue-next": "^0.577.0",
"motion": "^12.38.0",
"phaser": "^3.90.0",
"pinia": "^3.0.4",
"three": "^0.180.0",
"vue": "^3.5.30",

View File

@@ -0,0 +1,21 @@
# Pixel Command Cabin Demo
独立于 JARVIS 主应用的 Phaser 原型。
目标:
- 先验证“像素办公室 / 指挥作战室”方向是否成立
- 不接业务接口
- 先看空间感、状态切换、agent 行走和底部控制条
启动方式:
```powershell
cd E:\Code\Python\Projects\Jarvis\frontend\prototypes\pixel-command-cabin-demo
python -m http.server 4317
```
打开:
```text
http://127.0.0.1:4317
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,22 @@
UI Pack: Sci-fi (2.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 19-08-2024
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Some files were not shown because too many files have changed in this diff Show More