Compare commits
11 Commits
8c7cf0732b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 145c43f09c | |||
| 847d9f96db | |||
| 7f5b133fad | |||
| 21c869db62 | |||
| 1ca8855751 | |||
| d8f8b0c177 | |||
| 7e6eb6a7b3 | |||
| c70e7e7253 | |||
| 39a9058de1 | |||
| ac49c13965 | |||
| 3e39b40a50 |
@@ -35,6 +35,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
async def init_db():
|
async def init_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await ensure_task_columns(conn)
|
||||||
await ensure_log_columns(conn)
|
await ensure_log_columns(conn)
|
||||||
await ensure_message_columns(conn)
|
await ensure_message_columns(conn)
|
||||||
await ensure_conversation_columns(conn)
|
await ensure_conversation_columns(conn)
|
||||||
@@ -47,6 +48,195 @@ async def init_db():
|
|||||||
await ensure_learning_artifact_tables(conn)
|
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):
|
async def ensure_log_columns(conn):
|
||||||
result = await conn.execute(text("PRAGMA table_info(logs)"))
|
result = await conn.execute(text("PRAGMA table_info(logs)"))
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.routers import (
|
|||||||
terminal_router,
|
terminal_router,
|
||||||
tools_router,
|
tools_router,
|
||||||
remote_mount_router,
|
remote_mount_router,
|
||||||
|
office_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||||
@@ -133,6 +134,7 @@ app.include_router(agent_sessions_router)
|
|||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.include_router(tools_router)
|
app.include_router(tools_router)
|
||||||
app.include_router(remote_mount_router)
|
app.include_router(remote_mount_router)
|
||||||
|
app.include_router(office_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -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 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
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
|
|||||||
URGENT = "urgent"
|
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):
|
class Task(BaseModel):
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
title = Column(String(500), nullable=False)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
|
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||||
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
|
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
|
||||||
due_date = Column(DateTime, nullable=True)
|
due_date = Column(DateTime, nullable=True, index=True)
|
||||||
completed_at = Column(DateTime, nullable=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):
|
class TaskHistory(BaseModel):
|
||||||
__tablename__ = "task_histories"
|
__tablename__ = "task_histories"
|
||||||
|
|
||||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
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)
|
old_value = Column(Text, nullable=True)
|
||||||
new_value = Column(Text, nullable=True)
|
new_value = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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.terminal import router as terminal_router
|
||||||
from app.routers.tools import router as tools_router
|
from app.routers.tools import router as tools_router
|
||||||
from app.routers.remote_mount import router as remote_mount_router
|
from app.routers.remote_mount import router as remote_mount_router
|
||||||
|
from app.routers.office import router as office_router
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ async def chat(
|
|||||||
conversation_id=data.conversation_id,
|
conversation_id=data.conversation_id,
|
||||||
file_ids=data.file_ids,
|
file_ids=data.file_ids,
|
||||||
model_name=data.model_name,
|
model_name=data.model_name,
|
||||||
|
runtime=data.runtime,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
@@ -115,7 +116,7 @@ async def chat(
|
|||||||
conversation_id=conv_id,
|
conversation_id=conv_id,
|
||||||
message_id=msg_id,
|
message_id=msg_id,
|
||||||
content=content,
|
content=content,
|
||||||
agent_name="jarvis",
|
agent_name=data.runtime or "jarvis",
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ async def chat_stream(
|
|||||||
conversation_id=data.conversation_id,
|
conversation_id=data.conversation_id,
|
||||||
file_ids=data.file_ids,
|
file_ids=data.file_ids,
|
||||||
model_name=data.model_name,
|
model_name=data.model_name,
|
||||||
|
runtime=data.runtime,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
|
|||||||
179
backend/app/routers/office.py
Normal 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"}
|
||||||
@@ -1,25 +1,62 @@
|
|||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
from datetime import UTC, date, datetime
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.goal import Goal
|
from app.models.goal import Goal
|
||||||
from app.models.reminder import Reminder
|
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.todo import DailyTodo
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.schemas.schedule_center import (
|
from app.schemas.schedule_center import (
|
||||||
|
ScheduleCenterCommanderSummaryOut,
|
||||||
ScheduleCenterDateOut,
|
ScheduleCenterDateOut,
|
||||||
ScheduleCenterDaySummary,
|
ScheduleCenterDaySummary,
|
||||||
|
ScheduleCenterFocusTaskOut,
|
||||||
ScheduleCenterMonthOut,
|
ScheduleCenterMonthOut,
|
||||||
|
ScheduleCenterQuadrantOut,
|
||||||
|
ScheduleCenterQuadrantTaskOut,
|
||||||
)
|
)
|
||||||
|
from app.schemas.task import build_task_out
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
|
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(
|
def _build_summary(
|
||||||
target_date: str,
|
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)
|
@router.get("/month", response_model=ScheduleCenterMonthOut)
|
||||||
async def get_month_schedule(
|
async def get_month_schedule(
|
||||||
year: int = Query(..., ge=2000, le=2100),
|
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())
|
start_dt = datetime.combine(month_start, datetime.min.time())
|
||||||
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
||||||
|
|
||||||
todos = (await db.execute(
|
todos = (
|
||||||
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
|
await db.execute(
|
||||||
)).scalars().all()
|
select(DailyTodo).where(
|
||||||
tasks = (await db.execute(
|
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(
|
select(Task).where(
|
||||||
Task.user_id == current_user.id,
|
Task.user_id == current_user.id,
|
||||||
Task.due_date.is_not(None),
|
Task.due_date.is_not(None),
|
||||||
Task.due_date >= start_dt,
|
Task.due_date >= start_dt,
|
||||||
Task.due_date <= end_dt,
|
Task.due_date <= end_dt,
|
||||||
)
|
)
|
||||||
)).scalars().all()
|
)
|
||||||
reminders = (await db.execute(
|
).scalars().all()
|
||||||
|
reminders = (
|
||||||
|
await db.execute(
|
||||||
select(Reminder).where(
|
select(Reminder).where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
Reminder.reminder_at >= start_dt,
|
Reminder.reminder_at >= start_dt,
|
||||||
Reminder.reminder_at <= end_dt,
|
Reminder.reminder_at <= end_dt,
|
||||||
)
|
)
|
||||||
)).scalars().all()
|
)
|
||||||
goals = (await db.execute(
|
).scalars().all()
|
||||||
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
|
goals = (
|
||||||
)).scalars().all()
|
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]] = {}
|
todo_map: dict[str, list[DailyTodo]] = {}
|
||||||
for item in todos:
|
for item in todos:
|
||||||
@@ -96,18 +289,20 @@ async def get_month_schedule(
|
|||||||
days = []
|
days = []
|
||||||
for day in range(1, days_in_month + 1):
|
for day in range(1, days_in_month + 1):
|
||||||
date_key = month_start.replace(day=day).isoformat()
|
date_key = month_start.replace(day=day).isoformat()
|
||||||
days.append(_build_summary(
|
days.append(
|
||||||
|
_build_summary(
|
||||||
date_key,
|
date_key,
|
||||||
todo_map.get(date_key, []),
|
todo_map.get(date_key, []),
|
||||||
task_map.get(date_key, []),
|
task_map.get(date_key, []),
|
||||||
reminder_map.get(date_key, []),
|
reminder_map.get(date_key, []),
|
||||||
goal_map.get(date_key, []),
|
goal_map.get(date_key, []),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
|
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(
|
async def get_date_schedule(
|
||||||
date_str: date = Query(...),
|
date_str: date = Query(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -118,22 +313,28 @@ async def get_date_schedule(
|
|||||||
end_dt = datetime.combine(target_date, datetime.max.time())
|
end_dt = datetime.combine(target_date, datetime.max.time())
|
||||||
date_key = target_date.isoformat()
|
date_key = target_date.isoformat()
|
||||||
|
|
||||||
todos = (await db.execute(
|
todos = (
|
||||||
|
await db.execute(
|
||||||
select(DailyTodo)
|
select(DailyTodo)
|
||||||
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
||||||
.order_by(DailyTodo.created_at.desc())
|
.order_by(DailyTodo.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
tasks = (await db.execute(
|
).scalars().all()
|
||||||
|
tasks = (
|
||||||
|
await db.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
|
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||||
.where(
|
.where(
|
||||||
Task.user_id == current_user.id,
|
Task.user_id == current_user.id,
|
||||||
Task.due_date.is_not(None),
|
Task.due_date.is_not(None),
|
||||||
Task.due_date >= start_dt,
|
Task.due_date >= start_dt,
|
||||||
Task.due_date <= end_dt,
|
Task.due_date <= end_dt,
|
||||||
)
|
)
|
||||||
.order_by(Task.created_at.desc())
|
.order_by(Task.priority.desc(), Task.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
reminders = (await db.execute(
|
).scalars().unique().all()
|
||||||
|
reminders = (
|
||||||
|
await db.execute(
|
||||||
select(Reminder)
|
select(Reminder)
|
||||||
.where(
|
.where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
@@ -141,20 +342,26 @@ async def get_date_schedule(
|
|||||||
Reminder.reminder_at <= end_dt,
|
Reminder.reminder_at <= end_dt,
|
||||||
)
|
)
|
||||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||||
)).scalars().all()
|
)
|
||||||
goals = (await db.execute(
|
).scalars().all()
|
||||||
|
goals = (
|
||||||
|
await db.execute(
|
||||||
select(Goal)
|
select(Goal)
|
||||||
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
||||||
.order_by(Goal.created_at.desc())
|
.order_by(Goal.created_at.desc())
|
||||||
)).scalars().all()
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
||||||
return ScheduleCenterDateOut(
|
return ScheduleCenterDateOut(
|
||||||
date=date_key,
|
date=date_key,
|
||||||
todos=todos,
|
todos=todos,
|
||||||
tasks=tasks,
|
tasks=[build_task_out(task) for task in tasks],
|
||||||
reminders=reminders,
|
reminders=reminders,
|
||||||
goals=goals,
|
goals=goals,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
|
focus_tasks=_build_focus_tasks(tasks),
|
||||||
|
quadrants=_build_quadrants(tasks),
|
||||||
|
commander_summary=_build_commander_summary(tasks),
|
||||||
generated_at=datetime.now(UTC),
|
generated_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,116 @@
|
|||||||
|
import json
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
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.models.user import User
|
||||||
from app.routers.auth import get_current_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])
|
@router.get("", response_model=list[TaskOut])
|
||||||
@@ -18,12 +119,28 @@ async def list_tasks(
|
|||||||
due_date: date | None = Query(default=None),
|
due_date: date | None = Query(default=None),
|
||||||
date_from: date | None = Query(default=None),
|
date_from: date | None = Query(default=None),
|
||||||
date_to: 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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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:
|
if status:
|
||||||
query = query.where(Task.status == 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:
|
if due_date:
|
||||||
start = datetime.combine(due_date, datetime.min.time())
|
start = datetime.combine(due_date, datetime.min.time())
|
||||||
end = datetime.combine(due_date, datetime.max.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
|
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
|
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
|
||||||
if start and end and start > end:
|
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:
|
if start is not None:
|
||||||
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
||||||
if end is not None:
|
if end is not None:
|
||||||
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
|
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)
|
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(
|
async def create_task(
|
||||||
data: TaskCreate,
|
data: TaskCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
|
||||||
task = Task(
|
task = Task(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
priority=data.priority,
|
priority=data.priority,
|
||||||
due_date=data.due_date,
|
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)
|
db.add(task)
|
||||||
await db.commit()
|
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(
|
async def update_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
data: TaskUpdate,
|
data: TaskUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
result = await db.execute(
|
payload = data.model_dump(exclude_none=True)
|
||||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
previous_assignee = (task.assignee_type, task.assignee_id)
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
|
||||||
|
|
||||||
for field, value in data.model_dump(exclude_none=True).items():
|
for field, value in payload.items():
|
||||||
|
previous = getattr(task, field)
|
||||||
if field == "tags":
|
if field == "tags":
|
||||||
setattr(task, field, json.dumps(value))
|
task.tags = _encode_tags(value)
|
||||||
elif field == "status" and value == TaskStatus.DONE:
|
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
|
||||||
task.completed_at = datetime.now(UTC)
|
continue
|
||||||
setattr(task, field, value)
|
|
||||||
elif field == "status":
|
|
||||||
task.completed_at = None
|
|
||||||
setattr(task, field, value)
|
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.commit()
|
||||||
await db.refresh(task)
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
return task
|
return _task_detail_to_out(task)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}", status_code=204)
|
@router.delete("/{task_id}", status_code=204)
|
||||||
@@ -99,11 +260,171 @@ async def delete_task(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
||||||
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="任务不存在")
|
|
||||||
await db.delete(task)
|
await db.delete(task)
|
||||||
await db.commit()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class MessageCreate(BaseModel):
|
class MessageCreate(BaseModel):
|
||||||
@@ -37,6 +39,7 @@ class ChatRequest(BaseModel):
|
|||||||
conversation_id: str | None = None
|
conversation_id: str | None = None
|
||||||
agent_id: str | None = None
|
agent_id: str | None = None
|
||||||
model_name: str | None = None
|
model_name: str | None = None
|
||||||
|
runtime: Literal["jarvis", "hermes"] | None = None
|
||||||
file_ids: list[str] = []
|
file_ids: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from datetime import datetime
|
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.goal import GoalOut
|
||||||
from app.schemas.reminder import ReminderOut
|
from app.schemas.reminder import ReminderOut
|
||||||
from app.schemas.task import TaskOut
|
from app.schemas.task import TaskOut
|
||||||
@@ -18,6 +19,47 @@ class ScheduleCenterDaySummary(BaseModel):
|
|||||||
goal_total: int
|
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):
|
class ScheduleCenterMonthOut(BaseModel):
|
||||||
month: str
|
month: str
|
||||||
days: list[ScheduleCenterDaySummary]
|
days: list[ScheduleCenterDaySummary]
|
||||||
@@ -30,4 +72,9 @@ class ScheduleCenterDateOut(BaseModel):
|
|||||||
reminders: list[ReminderOut]
|
reminders: list[ReminderOut]
|
||||||
goals: list[GoalOut]
|
goals: list[GoalOut]
|
||||||
summary: ScheduleCenterDaySummary
|
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
|
generated_at: datetime
|
||||||
|
|||||||
@@ -1,14 +1,146 @@
|
|||||||
from pydantic import BaseModel
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
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):
|
class TaskCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
status: TaskStatus = TaskStatus.TODO
|
||||||
priority: TaskPriority = TaskPriority.MEDIUM
|
priority: TaskPriority = TaskPriority.MEDIUM
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
tags: list[str] | 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):
|
class TaskUpdate(BaseModel):
|
||||||
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
|
|||||||
priority: TaskPriority | None = None
|
priority: TaskPriority | None = None
|
||||||
due_date: datetime | None = None
|
due_date: datetime | None = None
|
||||||
tags: list[str] | 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):
|
class TaskOut(BaseModel):
|
||||||
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
|
|||||||
priority: TaskPriority
|
priority: TaskPriority
|
||||||
due_date: datetime | None
|
due_date: datetime | None
|
||||||
completed_at: 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
|
created_at: datetime
|
||||||
updated_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):
|
class DailyPlanRequest(BaseModel):
|
||||||
user_id: str
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
9
backend/app/services/agent_runtime/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
37
backend/app/services/agent_runtime/base.py
Normal 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]: ...
|
||||||
172
backend/app/services/agent_runtime/hermes_runtime.py
Normal 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()
|
||||||
37
backend/app/services/agent_runtime/hermes_session_manager.py
Normal 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()
|
||||||
21
backend/app/services/agent_runtime/jarvis_runtime.py
Normal 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()
|
||||||
@@ -42,6 +42,9 @@ from app.services.rollback_controller import RollbackController
|
|||||||
from app.services.runtime_observability import build_runtime_observability_report
|
from app.services.runtime_observability import build_runtime_observability_report
|
||||||
from app.agents.tools.time_reasoning import extract_reference_datetime
|
from app.agents.tools.time_reasoning import extract_reference_datetime
|
||||||
from app.agents.state import initial_state
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -378,6 +381,9 @@ class AgentService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
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 def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
await memory_service.try_auto_summarize(session, user_id, conversation_id)
|
||||||
@@ -662,10 +668,12 @@ class AgentService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
file_ids: list[str] | None = None,
|
file_ids: list[str] | None = None,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
|
runtime: str | None = None,
|
||||||
) -> tuple[str, str, AsyncGenerator[dict[str, Any], 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)
|
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||||
model_name_used = model_name
|
model_name_used = model_name
|
||||||
if model_name and not user_llm_config:
|
if model_name and not user_llm_config:
|
||||||
@@ -758,7 +766,7 @@ class AgentService:
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content="",
|
content="",
|
||||||
model=model_name_used or "jarvis",
|
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||||
attachments=None,
|
attachments=None,
|
||||||
)
|
)
|
||||||
self.db.add(assistant_msg)
|
self.db.add(assistant_msg)
|
||||||
@@ -773,10 +781,78 @@ class AgentService:
|
|||||||
"title": "Assistant message",
|
"title": "Assistant message",
|
||||||
"content_summary": content[:500],
|
"content_summary": content[:500],
|
||||||
"raw_excerpt": content[:2000],
|
"raw_excerpt": content[:2000],
|
||||||
"metadata_": {"role": "assistant"},
|
"metadata_": {"role": "assistant", "runtime": runtime_name},
|
||||||
"importance_signal": 0.8,
|
"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():
|
async def run_agent():
|
||||||
collected = ""
|
collected = ""
|
||||||
state: dict[str, Any] | None = None
|
state: dict[str, Any] | None = None
|
||||||
@@ -1003,10 +1079,12 @@ class AgentService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
file_ids: list[str] | None = None,
|
file_ids: list[str] | None = None,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
|
runtime: str | None = None,
|
||||||
) -> tuple[str, str, str, str | 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)
|
user_llm_config = await self._get_user_llm_config(user_id, model_name)
|
||||||
model_name_used = model_name
|
model_name_used = model_name
|
||||||
if model_name and not user_llm_config:
|
if model_name and not user_llm_config:
|
||||||
@@ -1043,7 +1121,7 @@ class AgentService:
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content="",
|
content="",
|
||||||
model=model_name_used or "jarvis",
|
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
|
||||||
attachments=None,
|
attachments=None,
|
||||||
)
|
)
|
||||||
self.db.add(assistant_msg)
|
self.db.add(assistant_msg)
|
||||||
@@ -1072,6 +1150,70 @@ class AgentService:
|
|||||||
if recall_ctx:
|
if recall_ctx:
|
||||||
memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else 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)
|
set_current_user(user_id)
|
||||||
try:
|
try:
|
||||||
graph = get_agent_graph()
|
graph = get_agent_graph()
|
||||||
|
|||||||
238
backend/app/services/task_dispatch.py
Normal 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()
|
||||||
75
backend/tests/backend/app/services/test_hermes_runtime.py
Normal 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
|
||||||
@@ -95,3 +95,65 @@ async def test_chat_stream_emits_error_event_when_agent_service_fails_before_str
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'event: error' in response.text
|
assert 'event: error' in response.text
|
||||||
assert 'stream boot failed' 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'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
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
|
@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
|
assert 'ix_learning_artifacts_artifact_type' in index_names
|
||||||
|
|
||||||
await engine.dispose()
|
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()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import sys
|
import sys
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
sys.modules.setdefault('psutil', Mock())
|
sys.modules.setdefault('psutil', Mock())
|
||||||
@@ -13,7 +14,7 @@ import app.models # noqa: F401
|
|||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
from app.models.goal import Goal
|
from app.models.goal import Goal
|
||||||
from app.models.reminder import Reminder
|
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.todo import DailyTodo, TodoSource
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_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])
|
session.add_all([user, other_user])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
session.add_all([
|
seeded_items = [
|
||||||
DailyTodo(
|
DailyTodo(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
title='Legacy todo',
|
title='Legacy todo',
|
||||||
@@ -78,13 +79,19 @@ async def schedule_env(tmp_path):
|
|||||||
title='High priority task',
|
title='High priority task',
|
||||||
priority=TaskPriority.HIGH,
|
priority=TaskPriority.HIGH,
|
||||||
status=TaskStatus.TODO,
|
status=TaskStatus.TODO,
|
||||||
|
source='schedule_center',
|
||||||
|
quadrant=TaskQuadrant.URGENT_IMPORTANT,
|
||||||
due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC),
|
due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC),
|
||||||
|
assignee_type='commander',
|
||||||
|
assignee_id='master',
|
||||||
|
dispatch_status=DispatchStatus.RUNNING,
|
||||||
),
|
),
|
||||||
Task(
|
Task(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
title='Urgent task next day',
|
title='Urgent task next day',
|
||||||
priority=TaskPriority.URGENT,
|
priority=TaskPriority.URGENT,
|
||||||
status=TaskStatus.IN_PROGRESS,
|
status=TaskStatus.IN_PROGRESS,
|
||||||
|
quadrant=TaskQuadrant.NOT_URGENT_IMPORTANT,
|
||||||
due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC),
|
due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC),
|
||||||
),
|
),
|
||||||
Task(
|
Task(
|
||||||
@@ -106,6 +113,30 @@ async def schedule_env(tmp_path):
|
|||||||
note='Ship MVP',
|
note='Ship MVP',
|
||||||
goal_date='2026-04-10',
|
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.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
@@ -211,10 +242,143 @@ async def test_get_schedule_center_date_returns_aggregated_resources(schedule_en
|
|||||||
'reminder_total': 1,
|
'reminder_total': 1,
|
||||||
'goal_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['reminders']] == ['Doctor reminder']
|
||||||
assert [item['title'] for item in payload['goals']] == ['Launch calendar beta']
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
|
async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
|
||||||
transport = ASGITransport(app=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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env):
|
async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env):
|
||||||
transport = ASGITransport(app=schedule_env)
|
transport = ASGITransport(app=schedule_env)
|
||||||
|
|||||||
221
backend/tests/backend/app/test_task_router.py
Normal 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
|
||||||
160
development-doc/plan/hermes-update/README.md
Normal 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、回滚策略。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# ADR:Hermes-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. 默认切换有灰度与回滚路径。
|
||||||
65
development-doc/plan/hermes-update/checklist.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Hermes-first 执行清单
|
||||||
|
|
||||||
|
## H0 Ownership / ADR
|
||||||
|
|
||||||
|
- [x] 新增 Hermes-first `README.md`
|
||||||
|
- [x] 新增 ADR:Hermes-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 条件与动作
|
||||||
|
- [ ] 用真实对话与指标验证默认切换时机
|
||||||
112
development-doc/plan/hermes-update/phase-h-0-recon-and-poc.md
Normal 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. 可能结论及后续影响
|
||||||
|
|
||||||
|
### 结论 A:Python chat 接口稳定
|
||||||
|
- 最优方案
|
||||||
|
- H-1/H-2 直接围绕 Python adapter + session manager 展开
|
||||||
|
|
||||||
|
### 结论 B:CLI `-q` + `resume` 稳定
|
||||||
|
- 可接受
|
||||||
|
- H-2 要更强调 session 句柄与进程生命周期管理
|
||||||
|
|
||||||
|
### 结论 C:只能稳定跑 TUI
|
||||||
|
- 风险显著升高
|
||||||
|
- 需重新评估是否值得继续集成
|
||||||
|
|
||||||
|
### 结论 D:当前环境无法稳定运行
|
||||||
|
- 可能需要 WSL2 或远程服务化托管
|
||||||
|
- 再决定是否继续推进
|
||||||
|
|
||||||
|
## 6. 验证清单
|
||||||
|
|
||||||
|
- [ ] 拉取 Hermes 仓库到隔离目录
|
||||||
|
- [ ] 明确 install 依赖与 Python 版本要求
|
||||||
|
- [ ] 确认单次 query 调用方式
|
||||||
|
- [ ] 确认 Python chat 接口是否可用
|
||||||
|
- [ ] 确认 session / resume 的可编程性
|
||||||
|
- [ ] 记录接入建议结论,作为 H-1 输入
|
||||||
@@ -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 事件协议未被破坏
|
||||||
@@ -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`
|
||||||
@@ -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 数据
|
||||||
@@ -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. 阶段结论输出
|
||||||
|
|
||||||
|
本阶段结束后,应明确给出以下结论之一:
|
||||||
|
|
||||||
|
### 结论 A:Hermes 明显更优
|
||||||
|
- 新开一轮“默认切换 / 逐步替换”规划
|
||||||
|
|
||||||
|
### 结论 B:Hermes 可保留为实验 runtime
|
||||||
|
- 不切默认
|
||||||
|
- 继续特定场景使用
|
||||||
|
|
||||||
|
### 结论 C:Hermes 不适合当前 Jarvis
|
||||||
|
- 中止替换计划
|
||||||
|
- 保留本轮探索结论供后续参考
|
||||||
@@ -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 才不会演变成“代码改了很多,但架构中心没有变”。
|
||||||
@@ -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 留出清晰边界
|
||||||
@@ -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 提供恢复所需元数据
|
||||||
@@ -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 有清晰记录
|
||||||
@@ -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 黑盒内部
|
||||||
@@ -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 契约
|
||||||
@@ -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 status(warm/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 开始成为一等状态
|
||||||
|
- [ ] 普通聊天体验保持稳定
|
||||||
@@ -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 默认切换不会成为不可逆操作
|
||||||
122
development-doc/plan/today-status-update/README.md
Normal 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 派发的共同前提。
|
||||||
92
development-doc/plan/today-status-update/checklist.md
Normal 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-2:Task 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-3:Chat 首页 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-5:Commander 派发闭环
|
||||||
|
|
||||||
|
- [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` 主路径不受影响
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
本阶段是后续所有实现文档的共识基础。
|
||||||
@@ -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-2(API)
|
||||||
|
→ Phase TS-4(详情编辑器)
|
||||||
|
→ Phase TS-5(Commander 派发)
|
||||||
|
```
|
||||||
|
|
||||||
|
本阶段是后续所有真实任务操作的基础。
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Phase TS-2:Task 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Phase TS-3:Chat 首页 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Phase TS-5:Commander 派发闭环
|
||||||
|
|
||||||
|
日期: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
|
||||||
|
```
|
||||||
90
development-doc/plan/war-room-update/README.md
Normal 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 结束前明确。
|
||||||
|
|
||||||
59
development-doc/plan/war-room-update/checklist.md
Normal 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`
|
||||||
|
- [ ] 清理无效按钮和占位行为
|
||||||
|
- [ ] 完成响应式回归
|
||||||
|
- [ ] 运行前端测试
|
||||||
|
- [ ] 运行后端测试
|
||||||
|
- [ ] 手测完整主链路
|
||||||
111
development-doc/plan/war-room-update/phase-wr-0-current-state.md
Normal 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
|
||||||
|
|
||||||
@@ -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 切换
|
||||||
|
|
||||||
@@ -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. 手测:
|
||||||
|
- 缩放和平移
|
||||||
|
- 复杂布局下的线条表现
|
||||||
|
|
||||||
@@ -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 无法完成。
|
||||||
|
|
||||||
@@ -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 联动
|
||||||
|
|
||||||
372
docs/superpowers/specs/2026-04-09-war-room-design.md
Normal 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(固定模板视图)
|
||||||
|
- OpsGrid(2x2 卡片网格)
|
||||||
|
- 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 页面的完整设计指南。*
|
||||||
16
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"lucide-vue-next": "^0.577.0",
|
"lucide-vue-next": "^0.577.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
|
"phaser": "^3.90.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
@@ -1963,6 +1964,12 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -3028,6 +3035,15 @@
|
|||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"lucide-vue-next": "^0.577.0",
|
"lucide-vue-next": "^0.577.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
|
"phaser": "^3.90.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
|||||||
21
frontend/prototypes/pixel-command-cabin-demo/README.md
Normal 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
|
||||||
|
```
|
||||||
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_0.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_1.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_2.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_3.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_4.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/prototypes/pixel-command-cabin-demo/assets/char_7.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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:
|
||||||
|
After Width: | Height: | Size: 547 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 126 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 331 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 333 B |
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 320 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 319 B |
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 190 B |
|
After Width: | Height: | Size: 126 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 204 B |
|
After Width: | Height: | Size: 209 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 205 B |