Compare commits

...

13 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:39 +08:00
8c7cf0732b Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
2026-04-09 17:26:37 +08:00
aa12c92a5a feat(temple): add Temple modal with Tools browser and Skills management 2026-04-08 16:46:02 +08:00
1286 changed files with 22827 additions and 1080 deletions

View File

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

View File

@@ -29,6 +29,9 @@ from app.routers import (
agent_skills_router, agent_skills_router,
agent_sessions_router, agent_sessions_router,
terminal_router, terminal_router,
tools_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
@@ -129,6 +132,9 @@ app.include_router(marketplace_router)
app.include_router(agent_skills_router) app.include_router(agent_skills_router)
app.include_router(agent_sessions_router) app.include_router(agent_sessions_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(tools_router)
app.include_router(remote_mount_router)
app.include_router(office_router)
@app.get("/api/health") @app.get("/api/health")

View File

@@ -2,7 +2,17 @@ from app.models.base import Base
from app.models.user import User from app.models.user import User
from app.models.folder import Folder from app.models.folder import Folder
from app.models.document import Document, DocumentChunk from app.models.document import Document, DocumentChunk
from app.models.task import Task, TaskHistory from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
from app.models.forum import ForumPost, ForumReply from app.models.forum import ForumPost, ForumReply
from app.models.agent import Agent, AgentMessage from app.models.agent import Agent, AgentMessage
from app.models.conversation import Conversation, Message from app.models.conversation import Conversation, Message
@@ -23,6 +33,7 @@ from app.models.reminder import Reminder, ReminderStatus
from app.models.goal import Goal, GoalStatus from app.models.goal import Goal, GoalStatus
from app.models.skill import Skill from app.models.skill import Skill
from app.models.log import Log, LogType, LogLevel from app.models.log import Log, LogType, LogLevel
from app.models.remote_mount import RemoteMount, RemoteSyncItem
__all__ = [ __all__ = [
"Base", "Base",
@@ -31,7 +42,14 @@ __all__ = [
"Document", "Document",
"DocumentChunk", "DocumentChunk",
"Task", "Task",
"TaskSubTask",
"TaskHistory", "TaskHistory",
"TaskStatus",
"TaskPriority",
"TaskSource",
"TaskQuadrant",
"TaskAssigneeType",
"TaskDispatchStatus",
"ForumPost", "ForumPost",
"ForumReply", "ForumReply",
"Agent", "Agent",
@@ -61,4 +79,6 @@ __all__ = [
"Log", "Log",
"LogType", "LogType",
"LogLevel", "LogLevel",
"RemoteMount",
"RemoteSyncItem",
] ]

View File

@@ -0,0 +1,34 @@
from sqlalchemy import Boolean, Column, ForeignKey, String, Text, UniqueConstraint
from app.models.base import BaseModel
class RemoteMount(BaseModel):
__tablename__ = "remote_mounts"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_remote_mount_user_name"),
)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(255), nullable=False)
mount_type = Column(String(32), nullable=False, default="webdav")
base_url = Column(String(1000), nullable=False)
username = Column(String(255), nullable=True)
password_encrypted = Column(Text, nullable=True)
root_path = Column(String(1000), nullable=False, default="/")
is_active = Column(Boolean, nullable=False, default=True)
last_sync_at = Column(String(64), nullable=True)
class RemoteSyncItem(BaseModel):
__tablename__ = "remote_sync_items"
mount_id = Column(String(36), ForeignKey("remote_mounts.id"), nullable=False, index=True)
remote_path = Column(String(2000), nullable=False)
remote_etag = Column(String(512), nullable=True)
remote_modified_at = Column(String(128), nullable=True)
local_folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
local_document_id = Column(String(36), ForeignKey("documents.id"), nullable=True)
sync_status = Column(String(32), nullable=False, default="synced")
last_error = Column(Text, nullable=True)
last_synced_at = Column(String(64), nullable=True)

View File

@@ -1,7 +1,8 @@
from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum as PyEnum from 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)

View File

@@ -21,3 +21,6 @@ from app.routers.plugins import _marketplace_router as marketplace_router
from app.routers.agent_skills import router as agent_skills_router from app.routers.agent_skills import router as agent_skills_router
from app.routers.agent_sessions import router as agent_sessions_router from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router from app.routers.terminal import router as terminal_router
from app.routers.tools import router as tools_router
from app.routers.remote_mount import router as remote_mount_router
from app.routers.office import router as office_router

View File

@@ -100,6 +100,7 @@ async def chat(
conversation_id=data.conversation_id, 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"

View File

@@ -1,17 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List from typing import List
import shutil
from app.database import get_db from app.database import get_db
from app.models.folder import Folder from app.models.folder import Folder
from app.models.user import User from app.models.user import User
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
from app.routers.auth import get_current_user from app.routers.auth import get_current_user
from app.schemas.folder import FolderCreate, FolderOut, FolderTreeOut, FolderUpdate
from app.services.document_service import DocumentService
router = APIRouter(prefix="/api/folders", tags=["文件夹"]) router = APIRouter(prefix="/api/folders", tags=["文件夹"])
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]: def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
"""递归构建文件夹树"""
tree = [] tree = []
for folder in folders: for folder in folders:
if folder.parent_id == parent_id: if folder.parent_id == parent_id:
@@ -20,30 +23,29 @@ def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[Fold
id=folder.id, id=folder.id,
name=folder.name, name=folder.name,
parent_id=folder.parent_id, parent_id=folder.parent_id,
children=children children=children,
)) ))
return tree return tree
@router.get("", response_model=List[FolderTreeOut]) @router.get("", response_model=List[FolderTreeOut])
async def get_folders( async def get_folders(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user),
): ):
"""获取用户的完整文件夹树"""
result = await db.execute( result = await db.execute(
select(Folder).where(Folder.user_id == current_user.id) select(Folder).where(Folder.user_id == current_user.id)
) )
folders = result.scalars().all() folders = result.scalars().all()
return build_folder_tree(list(folders)) return build_folder_tree(list(folders))
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
async def create_folder( async def create_folder(
folder_data: FolderCreate, folder_data: FolderCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user),
): ):
"""创建文件夹"""
# 验证父文件夹存在且属于当前用户
if folder_data.parent_id: if folder_data.parent_id:
result = await db.execute( result = await db.execute(
select(Folder).where( select(Folder).where(
@@ -53,13 +55,12 @@ async def create_folder(
if not result.scalar_one_or_none(): if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="父文件夹不存在") raise HTTPException(status_code=404, detail="父文件夹不存在")
# 检查同名文件夹
result = await db.execute( result = await db.execute(
select(Folder).where( select(Folder).where(
and_( and_(
Folder.user_id == current_user.id, Folder.user_id == current_user.id,
Folder.parent_id == folder_data.parent_id, Folder.parent_id == folder_data.parent_id,
Folder.name == folder_data.name Folder.name == folder_data.name,
) )
) )
) )
@@ -69,21 +70,24 @@ async def create_folder(
folder = Folder( folder = Folder(
user_id=current_user.id, user_id=current_user.id,
name=folder_data.name, name=folder_data.name,
parent_id=folder_data.parent_id parent_id=folder_data.parent_id,
) )
db.add(folder) db.add(folder)
await db.commit() await db.commit()
await db.refresh(folder) await db.refresh(folder)
document_service = DocumentService(db, current_user.id)
await document_service.ensure_folder_directory(current_user.id, folder.id)
return folder return folder
@router.put("/{folder_id}", response_model=FolderOut) @router.put("/{folder_id}", response_model=FolderOut)
async def rename_folder( async def rename_folder(
folder_id: str, folder_id: str,
folder_data: FolderUpdate, folder_data: FolderUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user),
): ):
"""重命名文件夹"""
result = await db.execute( result = await db.execute(
select(Folder).where( select(Folder).where(
and_(Folder.id == folder_id, Folder.user_id == current_user.id) and_(Folder.id == folder_id, Folder.user_id == current_user.id)
@@ -93,18 +97,22 @@ async def rename_folder(
if not folder: if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在") raise HTTPException(status_code=404, detail="文件夹不存在")
old_name = folder.name
folder.name = folder_data.name folder.name = folder_data.name
document_service = DocumentService(db, current_user.id)
await document_service.rename_folder_directory(current_user.id, folder.id, old_name, folder_data.name)
await db.commit() await db.commit()
await db.refresh(folder) await db.refresh(folder)
return folder return folder
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder( async def delete_folder(
folder_id: str, folder_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user),
): ):
"""删除文件夹(级联删除文档)"""
from app.models.document import Document from app.models.document import Document
from app.services.knowledge_service import KnowledgeService from app.services.knowledge_service import KnowledgeService
@@ -117,15 +125,16 @@ async def delete_folder(
if not folder: if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在") raise HTTPException(status_code=404, detail="文件夹不存在")
document_service = DocumentService(db, current_user.id)
folder_path = await document_service._get_storage_directory(current_user.id, folder_id)
async def delete_recursive(fid: str): async def delete_recursive(fid: str):
# 删除子文件夹(先递归)
children = await db.execute( children = await db.execute(
select(Folder).where(Folder.parent_id == fid) select(Folder).where(Folder.parent_id == fid)
) )
for child in children.scalars(): for child in children.scalars():
await delete_recursive(child.id) await delete_recursive(child.id)
# 删除文档
docs = await db.execute( docs = await db.execute(
select(Document).where(Document.folder_id == fid) select(Document).where(Document.folder_id == fid)
) )
@@ -134,10 +143,12 @@ async def delete_folder(
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id) await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
await db.delete(doc) await db.delete(doc)
# 删除文件夹本身
folder_to_delete = await db.get(Folder, fid) folder_to_delete = await db.get(Folder, fid)
if folder_to_delete: if folder_to_delete:
await db.delete(folder_to_delete) await db.delete(folder_to_delete)
await delete_recursive(folder_id) await delete_recursive(folder_id)
await db.commit() await db.commit()
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)

View File

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

View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.remote_mount import RemoteMount
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.remote_mount import (
RemoteMountCreate,
RemoteMountOut,
RemoteMountTreeOut,
RemoteNodeOut,
RemoteSyncRequest,
RemoteSyncResultOut,
)
from app.services.remote_sync_service import RemoteSyncService
from app.services.secret_service import encrypt_secret
from app.services.webdav_service import WebDavNode, WebDavService
router = APIRouter(prefix="/api/remote-mounts", tags=["远程挂载"])
def _to_node_out(node: WebDavNode) -> RemoteNodeOut:
return RemoteNodeOut(
path=node.path,
name=node.name,
is_dir=node.is_dir,
size=node.size,
modified_at=node.modified_at,
etag=node.etag,
children=[_to_node_out(child) for child in node.children],
)
@router.get("", response_model=list[RemoteMountOut])
async def list_remote_mounts(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RemoteMount).where(RemoteMount.user_id == current_user.id).order_by(RemoteMount.created_at.desc())
)
return list(result.scalars().all())
@router.post("", response_model=RemoteMountOut, status_code=status.HTTP_201_CREATED)
async def create_remote_mount(
payload: RemoteMountCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
existing = await db.execute(
select(RemoteMount).where(and_(RemoteMount.user_id == current_user.id, RemoteMount.name == payload.name))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="同名远程挂载已存在")
mount = RemoteMount(
user_id=current_user.id,
name=payload.name,
mount_type="webdav",
base_url=str(payload.base_url),
username=payload.username,
password_encrypted=encrypt_secret(payload.password),
root_path=payload.root_path,
is_active=True,
)
try:
await WebDavService(mount).list_directory(payload.root_path)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"WebDAV 连接失败: {exc}") from exc
db.add(mount)
await db.commit()
await db.refresh(mount)
return mount
async def _get_user_mount(db: AsyncSession, user_id: str, mount_id: str) -> RemoteMount:
result = await db.execute(
select(RemoteMount).where(and_(RemoteMount.id == mount_id, RemoteMount.user_id == user_id))
)
mount = result.scalar_one_or_none()
if mount is None:
raise HTTPException(status_code=404, detail="远程挂载不存在")
return mount
@router.get("/{mount_id}/tree", response_model=RemoteMountTreeOut)
async def get_remote_tree(
mount_id: str,
path: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mount = await _get_user_mount(db, current_user.id, mount_id)
try:
nodes = await WebDavService(mount).list_tree(path or mount.root_path)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"远程目录读取失败: {exc}") from exc
return RemoteMountTreeOut(
mount_id=mount.id,
root_path=path or mount.root_path,
nodes=[_to_node_out(node) for node in nodes],
)
@router.post("/{mount_id}/sync", response_model=RemoteSyncResultOut)
async def sync_remote_mount(
mount_id: str,
payload: RemoteSyncRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mount = await _get_user_mount(db, current_user.id, mount_id)
try:
result = await RemoteSyncService(db, current_user.id).sync_remote_path(
mount,
payload.remote_path,
payload.local_folder_id,
payload.mode,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"远程同步失败: {exc}") from exc
return RemoteSyncResultOut(**result)

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
"""Tools API Router
聚合两套工具体系的元数据:
1. 注册层 (app/tools/) - YAML manifest 定义
2. Agent 层 (app/agents/tools/) - @tool 装饰器定义
"""
import re
import importlib
from fastapi import APIRouter, Depends
from app.routers.auth import get_current_user
from app.models.user import User
from app.schemas.tools import (
ToolsResponse,
ToolCategory,
ToolSubgroup,
ToolInfo,
ToolCommand,
ToolStats,
ToolSummary,
)
router = APIRouter(prefix="/api/tools", tags=["Tools"])
# ============================================================
# 辅助函数
# ============================================================
def _parse_command_from_docstring(docstring: str) -> dict:
"""从函数的 docstring 解析参数信息"""
params = {"type": "object", "properties": {}, "required": []}
if not docstring:
return params
# 简单解析 Args: 段落
args_match = re.search(
r"Args:\s*(.*?)(?=\n\s*(?:Returns?|Raises?)|$", docstring, re.DOTALL | re.IGNORECASE
)
if args_match:
args_section = args_match.group(1)
# 匹配形如 "arg_name (type): description" 的行
for line in args_section.strip().split("\n"):
line = line.strip()
if not line:
continue
# 匹配: "name (type): description" 或 "name: description"
m = re.match(r"(\w+)\s*(?:\(\s*(\w+)\s*\))?\s*:", line)
if m:
param_name = m.group(1)
params["properties"][param_name] = {"type": "string", "description": line}
params["required"].append(param_name)
return params
def _build_agent_tools() -> list[ToolInfo]:
"""扫描 app/agents/tools/ 目录,内省 @tool 装饰器"""
tools: list[ToolInfo] = []
# 分类映射:文件名 -> (分类名, 子分类名)
category_map = {
"search": ("Agent层", "知识检索"),
"schedule": ("Agent层", "日程管理"),
"task": ("Agent层", "任务管理"),
"forum": ("Agent层", "论坛功能"),
"time_reasoning": ("Agent层", "时间推理"),
"builtins/file_tools": ("Agent层", "文件工具"),
"builtins/system_tools": ("Agent层", "系统命令"),
"builtins/dev_tools": ("Agent层", "开发工具"),
"builtins/collaboration_tools": ("Agent层", "协作工具"),
}
# 工具名称 -> 中文显示名
display_names = {
"search_knowledge": "知识库搜索",
"get_knowledge_graph_context": "知识图谱查询",
"build_knowledge_graph": "构建知识图谱",
"hybrid_search": "混合搜索",
"web_search": "联网搜索",
"get_schedule_day": "获取日程",
"create_todo": "创建待办",
"create_schedule_task": "创建日程任务",
"create_reminder": "创建提醒",
"create_goal": "创建目标",
"get_tasks": "获取任务列表",
"create_task": "创建任务",
"update_task_status": "更新任务状态",
"get_forum_posts": "获取论坛帖子",
"create_forum_post": "发布论坛帖子",
"scan_forum_for_instructions": "扫描论坛指令",
"resolve_time_expression": "解析时间表达式",
"glob": "文件路径匹配",
"grep": "文件内容搜索",
"read_file": "读取文件",
"write_file": "写入文件",
"bash": "Bash命令",
"powershell": "PowerShell命令",
"git": "Git操作",
"lsp_tools": "LSP代码导航",
"team_agent": "团队Agent通信",
"task_broadcast": "任务广播",
}
# 工具描述
descriptions = {
"search_knowledge": "搜索用户的私人知识库,返回最相关的文档片段",
"get_knowledge_graph_context": "获取用户知识图谱的上下文信息",
"build_knowledge_graph": "从文档构建/更新知识图谱",
"hybrid_search": "混合搜索,结合向量语义检索和关键词匹配",
"web_search": "通过 SearxNG 搜索外部网页信息",
"get_schedule_day": "获取指定日期的 todo/task/reminder/goal 聚合信息",
"create_todo": "创建指定日期的待办",
"create_schedule_task": "创建任务,支持优先级和截止日期",
"create_reminder": "创建提醒,支持自然语言时间",
"create_goal": "创建指定日期的目标",
"get_tasks": "获取用户当前的任务列表",
"create_task": "创建新任务",
"update_task_status": "更新任务状态",
"get_forum_posts": "获取论坛帖子列表",
"create_forum_post": "在论坛发布新帖子",
"scan_forum_for_instructions": "扫描论坛中的指令类帖子",
"resolve_time_expression": "解析中文自然语言时间表达",
"glob": "使用 glob 模式查找文件路径",
"grep": "在文件中搜索匹配的文本行",
"read_file": "读取文件内容",
"write_file": "写入文件内容",
"bash": "执行 Bash 命令",
"powershell": "执行 PowerShell 命令",
"git": "执行 Git 命令",
"lsp_tools": "LSP 代码导航和查找引用",
"team_agent": "向团队 Agent 发送消息或请求协作",
"task_broadcast": "向多个 Agent 广播任务",
}
# 需要扫描的模块
modules_to_scan = [
("app.agents.tools.search", "search"),
("app.agents.tools.schedule", "schedule"),
("app.agents.tools.task", "task"),
("app.agents.tools.forum", "forum"),
("app.agents.tools.time_reasoning", "time_reasoning"),
("app.agents.tools.builtins.file_tools", "builtins/file_tools"),
("app.agents.tools.builtins.system_tools", "builtins/system_tools"),
("app.agents.tools.builtins.dev_tools", "builtins/dev_tools"),
("app.agents.tools.builtins.collaboration_tools", "builtins/collaboration_tools"),
]
for module_name, category_key in modules_to_scan:
try:
mod = importlib.import_module(module_name)
except ImportError:
continue
# 扫描模块中所有 @tool 装饰的函数
for attr_name in dir(mod):
if attr_name.startswith("_"):
continue
attr = getattr(mod, attr_name)
# 检查是否是 langchain @tool 装饰的对象
if hasattr(attr, "name") and hasattr(attr, "description"):
tool_name = attr.name
tool_desc = attr.description or ""
# 清理 docstring 中的参数说明用于显示
display_desc = re.sub(r"\s*Args:\s*.*", "", tool_desc, flags=re.DOTALL).strip()
display_desc = re.sub(
r"\s*Returns?:\s*.*", "", display_desc, flags=re.DOTALL
).strip()
# 获取 category 和 subcategory
cat_info = category_map.get(category_key, ("Agent层", category_key))
category, subcategory = cat_info[0], cat_info[1]
# 获取参数 schema
params_schema = getattr(attr, "args_schema", None)
parameters = {}
if params_schema:
try:
if hasattr(params_schema, "model_json_schema"):
parameters = params_schema.model_json_schema()
elif hasattr(params_schema, "schema"):
parameters = params_schema.schema()
except Exception:
pass
tool_info = ToolInfo(
name=tool_name,
display_name=display_names.get(tool_name, tool_name),
description=descriptions.get(tool_name, display_desc or tool_desc),
category=category,
subcategory=subcategory,
source="agent",
source_file=module_name,
tags=[],
enabled=True,
commands=[
ToolCommand(
name=tool_name,
description=tool_desc or display_desc,
parameters=parameters,
)
],
stats=ToolStats(),
)
tools.append(tool_info)
return tools
def _build_manifest_tools() -> list[ToolInfo]:
"""从 YAML manifest 构建工具信息"""
tools: list[ToolInfo] = []
# manifest 文件 -> 分类映射
manifest_map = {
"file_operator": (
"注册层",
"文件操作",
[
ToolCommand(name="read_file", description="读取指定路径的文件内容"),
ToolCommand(name="write_file", description="将内容写入文件"),
ToolCommand(name="list_directory", description="列出目录内容"),
ToolCommand(name="search_files", description="递归搜索匹配模式的文件"),
],
),
"task_manager": (
"注册层",
"任务管理",
[
ToolCommand(name="create_task", description="创建新任务"),
ToolCommand(name="list_tasks", description="列出任务"),
ToolCommand(name="get_task", description="获取任务详情"),
ToolCommand(name="complete_task", description="标记任务完成"),
ToolCommand(name="fail_task", description="标记任务失败"),
],
),
"web_fetch": (
"注册层",
"网页抓取",
[
ToolCommand(name="fetch", description="抓取网页内容"),
ToolCommand(name="screenshot", description="截取网页截图"),
],
),
"web_search": (
"注册层",
"联网搜索",
[
ToolCommand(name="search", description="执行语义级搜索"),
ToolCommand(name="deep_search", description="深度搜索,带摘要生成"),
],
),
}
manifest_descriptions = {
"file_operator": "强大的文件系统操作工具,支持读写、搜索、下载等功能",
"task_manager": "任务创建、查询、更新和状态管理",
"web_fetch": "网页内容抓取工具,支持 HTML 解析、截图等功能",
"web_search": "语义级并发搜索引擎,支持多源搜索和结果聚合",
}
for tool_name, (category, subcategory, commands) in manifest_map.items():
tool_info = ToolInfo(
name=tool_name,
display_name=subcategory,
description=manifest_descriptions.get(tool_name, ""),
category=category,
subcategory=subcategory,
source="manifest",
source_file=f"app/tools/manifests/{tool_name}.yaml",
tags=[],
enabled=True,
commands=commands,
stats=ToolStats(),
)
tools.append(tool_info)
return tools
# ============================================================
# 路由
# ============================================================
@router.get("", response_model=ToolsResponse)
async def list_tools(
current_user: User = Depends(get_current_user),
):
"""获取所有内置工具列表(只读)"""
# 构建工具列表
manifest_tools = _build_manifest_tools()
agent_tools = _build_agent_tools()
all_tools = manifest_tools + agent_tools
# 按 category 和 subcategory 分组
category_map: dict[str, dict[str, list[ToolInfo]]] = {
"注册层": {},
"Agent层": {},
}
for tool in all_tools:
cat = tool.category
subcat = tool.subcategory
if cat not in category_map:
category_map[cat] = {}
if subcat not in category_map[cat]:
category_map[cat][subcat] = []
category_map[cat][subcat].append(tool)
# 构建响应
categories = []
for cat_name, subgroups_dict in category_map.items():
if not subgroups_dict:
continue
subgroups = []
for subcat_name, tools_list in subgroups_dict.items():
subgroups.append(
ToolSubgroup(
name=subcat_name,
display_name=subcat_name,
tools=tools_list,
)
)
categories.append(
ToolCategory(
name=cat_name,
display_name=cat_name,
subgroups=subgroups,
)
)
# 计算摘要
total_commands = sum(len(t.commands) for t in all_tools)
active_commands = sum(len(t.commands) for t in all_tools if t.enabled)
summary = ToolSummary(
total_commands=total_commands,
active_commands=active_commands,
total_tools=len(all_tools),
manifest_tools=len(manifest_tools),
agent_tools=len(agent_tools),
)
return ToolsResponse(categories=categories, summary=summary)

View File

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

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl
class RemoteMountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
base_url: HttpUrl
username: str | None = Field(default=None, max_length=255)
password: str | None = Field(default=None, max_length=2000)
root_path: str = Field(default="/", min_length=1, max_length=1000)
class RemoteMountOut(BaseModel):
id: str
name: str
mount_type: str
base_url: str
username: str | None
root_path: str
is_active: bool
last_sync_at: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class RemoteNodeOut(BaseModel):
path: str
name: str
is_dir: bool
size: int | None = None
modified_at: str | None = None
etag: str | None = None
children: list["RemoteNodeOut"] = []
class RemoteMountTreeOut(BaseModel):
mount_id: str
root_path: str
nodes: list[RemoteNodeOut]
class RemoteSyncRequest(BaseModel):
remote_path: str = Field(..., min_length=1, max_length=2000)
local_folder_id: str = Field(..., min_length=1, max_length=36)
mode: str = Field(default="file", pattern="^(file|folder)$")
class RemoteSyncResultOut(BaseModel):
synced: int
skipped: int
failed: int
document_ids: list[str]
errors: list[str]
RemoteNodeOut.model_rebuild()

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
"""Tools API Schemas"""
from pydantic import BaseModel
from typing import Optional
class ToolCommand(BaseModel):
"""单个工具命令"""
name: str
description: str
parameters: dict = {}
class ToolStats(BaseModel):
"""工具调用统计"""
call_count: int = 0
error_count: int = 0
total_duration_ms: int = 0
avg_duration_ms: int = 0
error_rate: float = 0.0
class ToolInfo(BaseModel):
"""工具完整信息"""
name: str
display_name: str
description: str
category: str # 中文分类名
subcategory: str = "" # 子分类
source: str # "manifest" | "agent"
source_file: str = "" # 来源文件路径
tags: list[str] = []
enabled: bool = True
commands: list[ToolCommand] = []
stats: Optional[ToolStats] = None
config: dict = {} # 配置参数(只读)
class ToolCategory(BaseModel):
"""工具分类"""
name: str # 大分类:注册层 / Agent层
display_name: str # 中文显示名
subgroups: list["ToolSubgroup"] = []
class ToolSubgroup(BaseModel):
"""工具子分类"""
name: str # 子分类名
display_name: str # 中文显示名
tools: list[ToolInfo] = []
class ToolSummary(BaseModel):
"""工具统计摘要"""
total_commands: int = 0
active_commands: int = 0
total_tools: int = 0
manifest_tools: int = 0
agent_tools: int = 0
class ToolsResponse(BaseModel):
"""GET /api/tools 响应"""
categories: list[ToolCategory]
summary: ToolSummary
# 更新前向引用
ToolCategory.model_rebuild()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,9 @@ from app.services.rollback_controller import RollbackController
from app.services.runtime_observability import build_runtime_observability_report from app.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()

View File

@@ -5,6 +5,7 @@
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import shutil
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from fastapi import UploadFile from fastapi import UploadFile
@@ -18,7 +19,6 @@ import json
import os import os
import re import re
import aiofiles import aiofiles
import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -52,9 +52,9 @@ class DocumentService:
if ext not in ALLOWED_EXTENSIONS: if ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"不支持的文件类型: {ext}") raise ValueError(f"不支持的文件类型: {ext}")
os.makedirs(settings.UPLOAD_DIR, exist_ok=True) folder_path = await self._get_storage_directory(user_id, folder_id)
file_id = str(uuid.uuid4()) folder_path.mkdir(parents=True, exist_ok=True)
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}") file_path = self._resolve_unique_file_path(folder_path, file.filename)
content = await file.read() content = await file.read()
file_size = len(content) file_size = len(content)
@@ -64,7 +64,7 @@ class DocumentService:
async with aiofiles.open(file_path, "wb") as f: async with aiofiles.open(file_path, "wb") as f:
await f.write(content) await f.write(content)
parsed = await self._parse_document(file_path, ext) parsed = await self._parse_document(str(file_path), ext)
parsed.structured_markdown = self._render_structured_markdown(parsed) parsed.structured_markdown = self._render_structured_markdown(parsed)
doc = Document( doc = Document(
@@ -73,7 +73,7 @@ class DocumentService:
filename=file.filename, filename=file.filename,
file_type=ext[1:], file_type=ext[1:],
file_size=file_size, file_size=file_size,
file_path=file_path, file_path=str(file_path),
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary, summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
folder_id=folder_id, folder_id=folder_id,
ingestion_status="uploaded", ingestion_status="uploaded",
@@ -171,6 +171,83 @@ class DocumentService:
return "/" + "/".join(path_parts) if path_parts else None return "/" + "/".join(path_parts) if path_parts else None
async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path:
folder_path = await self._get_storage_directory(user_id, folder_id)
folder_path.mkdir(parents=True, exist_ok=True)
return folder_path
async def delete_folder_directory(self, user_id: str, folder_id: str) -> None:
folder_path = await self._get_storage_directory(user_id, folder_id)
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)
async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None:
folder = await self.db.get(Folder, folder_id)
if folder is None:
return
parent_path = await self._get_storage_directory(user_id, folder.parent_id)
old_path = parent_path / self._sanitize_storage_name(old_name)
new_path = parent_path / self._sanitize_storage_name(new_name)
if old_path != new_path:
parent_path.mkdir(parents=True, exist_ok=True)
if old_path.exists():
old_path.rename(new_path)
else:
new_path.mkdir(parents=True, exist_ok=True)
else:
new_path.mkdir(parents=True, exist_ok=True)
document_result = await self.db.execute(
select(Document).where(Document.user_id == user_id)
)
for document in document_result.scalars().all():
try:
relative_path = Path(document.file_path).relative_to(old_path)
except ValueError:
continue
document.file_path = str(new_path / relative_path)
async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path:
base_path = Path(settings.UPLOAD_DIR) / user_id
if not folder_id:
return base_path
folders = await self.db.execute(
select(Folder).where(Folder.user_id == user_id)
)
folder_map = {folder.id: folder for folder in folders.scalars().all()}
path_segments: list[str] = []
current_id = folder_id
while current_id:
folder = folder_map.get(current_id)
if folder is None:
raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?")
path_segments.insert(0, self._sanitize_storage_name(folder.name))
current_id = folder.parent_id
return base_path.joinpath(*path_segments)
def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path:
safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True)
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
candidate = directory / safe_name
counter = 2
while candidate.exists():
candidate = directory / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str:
invalid_chars = '<>:"/\\|?*'
sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.')
if not sanitized:
return 'untitled' if is_file else 'folder'
return sanitized
async def delete_document(self, user_id: str, document_id: str): async def delete_document(self, user_id: str, document_id: str):
result = await self.db.execute( result = await self.db.execute(
select(Document).where( select(Document).where(

View File

@@ -0,0 +1,108 @@
from io import BytesIO
from datetime import UTC, datetime
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.datastructures import UploadFile
from app.models.folder import Folder
from app.models.remote_mount import RemoteMount, RemoteSyncItem
from app.services.document_service import DocumentService
from app.services.webdav_service import WebDavNode, WebDavService
class RemoteSyncService:
def __init__(self, db: AsyncSession, user_id: str):
self.db = db
self.user_id = user_id
async def sync_remote_path(
self,
mount: RemoteMount,
remote_path: str,
local_folder_id: str,
mode: str = "file",
) -> dict:
folder = await self.db.execute(
select(Folder).where(and_(Folder.id == local_folder_id, Folder.user_id == self.user_id))
)
if folder.scalar_one_or_none() is None:
raise ValueError("本地目标文件夹不存在")
webdav = WebDavService(mount)
document_service = DocumentService(self.db, self.user_id)
synced = 0
skipped = 0
failed = 0
document_ids: list[str] = []
errors: list[str] = []
if mode == "folder":
nodes = await webdav.list_tree(remote_path)
targets = self._flatten_files(nodes)
else:
name = remote_path.rstrip("/").split("/")[-1] or "remote-file"
targets = [WebDavNode(path=remote_path, name=name, is_dir=False)]
for node in targets:
try:
content, filename = await webdav.download_file(node.path)
upload = UploadFile(filename=filename, file=BytesIO(content))
document = await document_service.upload_document(self.user_id, upload, folder_id=local_folder_id)
await self._upsert_sync_item(mount.id, node, local_folder_id, document.id)
document_ids.append(document.id)
synced += 1
except Exception as exc: # noqa: BLE001
failed += 1
errors.append(f"{node.path}: {exc}")
await self._upsert_sync_item(mount.id, node, local_folder_id, None, status="failed", error=str(exc))
mount.last_sync_at = datetime.now(UTC).isoformat()
await self.db.commit()
return {
"synced": synced,
"skipped": skipped,
"failed": failed,
"document_ids": document_ids,
"errors": errors,
}
def _flatten_files(self, nodes: list[WebDavNode]) -> list[WebDavNode]:
results: list[WebDavNode] = []
for node in nodes:
if node.is_dir:
results.extend(self._flatten_files(node.children))
else:
results.append(node)
return results
async def _upsert_sync_item(
self,
mount_id: str,
node: WebDavNode,
local_folder_id: str,
local_document_id: str | None,
status: str = "synced",
error: str | None = None,
) -> None:
result = await self.db.execute(
select(RemoteSyncItem).where(
and_(RemoteSyncItem.mount_id == mount_id, RemoteSyncItem.remote_path == node.path)
)
)
sync_item = result.scalar_one_or_none()
if sync_item is None:
sync_item = RemoteSyncItem(
mount_id=mount_id,
remote_path=node.path,
)
self.db.add(sync_item)
sync_item.remote_etag = node.etag
sync_item.remote_modified_at = node.modified_at
sync_item.local_folder_id = local_folder_id
sync_item.local_document_id = local_document_id
sync_item.sync_status = status
sync_item.last_error = error
sync_item.last_synced_at = datetime.now(UTC).isoformat()

View File

@@ -0,0 +1,24 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from app.config import settings
def _build_fernet() -> Fernet:
digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")

View File

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

View File

@@ -0,0 +1,127 @@
from dataclasses import dataclass, field
from urllib.parse import quote, urljoin
import xml.etree.ElementTree as ET
import httpx
from app.models.remote_mount import RemoteMount
from app.services.secret_service import decrypt_secret
WEBDAV_NAMESPACE = {
"d": "DAV:",
}
@dataclass
class WebDavNode:
path: str
name: str
is_dir: bool
size: int | None = None
modified_at: str | None = None
etag: str | None = None
children: list["WebDavNode"] = field(default_factory=list)
class WebDavService:
def __init__(self, mount: RemoteMount):
self.mount = mount
self.username = mount.username or None
self.password = decrypt_secret(mount.password_encrypted)
def _normalize_remote_path(self, remote_path: str | None = None) -> str:
path = remote_path or self.mount.root_path or "/"
if not path.startswith("/"):
path = f"/{path}"
return path
def _build_url(self, remote_path: str | None = None) -> str:
path = self._normalize_remote_path(remote_path)
encoded = "/".join(quote(segment) for segment in path.split("/") if segment)
if not encoded:
return self.mount.base_url.rstrip("/") + "/"
return urljoin(self.mount.base_url.rstrip("/") + "/", encoded)
async def list_directory(self, remote_path: str | None = None) -> list[WebDavNode]:
path = self._normalize_remote_path(remote_path)
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname />
<d:resourcetype />
<d:getcontentlength />
<d:getlastmodified />
<d:getetag />
</d:prop>
</d:propfind>"""
async with httpx.AsyncClient(timeout=30.0, auth=self._auth()) as client:
response = await client.request(
"PROPFIND",
self._build_url(path),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=body,
)
response.raise_for_status()
return self._parse_propfind(path, response.text)
async def list_tree(self, remote_path: str | None = None, max_depth: int = 4) -> list[WebDavNode]:
path = self._normalize_remote_path(remote_path)
nodes = await self.list_directory(path)
if max_depth <= 1:
return nodes
for node in nodes:
if node.is_dir:
node.children = await self.list_tree(node.path, max_depth=max_depth - 1)
return nodes
async def download_file(self, remote_path: str) -> tuple[bytes, str]:
normalized = self._normalize_remote_path(remote_path)
async with httpx.AsyncClient(timeout=120.0, auth=self._auth()) as client:
response = await client.get(self._build_url(normalized))
response.raise_for_status()
name = normalized.rstrip("/").split("/")[-1] or "remote-file"
return response.content, name
def _auth(self) -> httpx.BasicAuth | None:
if self.username and self.password:
return httpx.BasicAuth(self.username, self.password)
return None
def _parse_propfind(self, parent_path: str, payload: str) -> list[WebDavNode]:
root = ET.fromstring(payload)
nodes: list[WebDavNode] = []
for response in root.findall("d:response", WEBDAV_NAMESPACE):
href = response.findtext("d:href", default="", namespaces=WEBDAV_NAMESPACE)
if not href:
continue
normalized_href = "/" + href.split("://", 1)[-1].split("/", 1)[-1].strip("/")
normalized_href = "/" if normalized_href == "/" else normalized_href.rstrip("/")
normalized_parent = self._normalize_remote_path(parent_path).rstrip("/") or "/"
if normalized_href.rstrip("/") == normalized_parent.rstrip("/"):
continue
prop = response.find("d:propstat/d:prop", WEBDAV_NAMESPACE)
if prop is None:
continue
is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NAMESPACE) is not None
display_name = prop.findtext("d:displayname", default="", namespaces=WEBDAV_NAMESPACE) or normalized_href.split("/")[-1]
size_text = prop.findtext("d:getcontentlength", default="", namespaces=WEBDAV_NAMESPACE)
etag = prop.findtext("d:getetag", default=None, namespaces=WEBDAV_NAMESPACE)
modified_at = prop.findtext("d:getlastmodified", default=None, namespaces=WEBDAV_NAMESPACE)
nodes.append(WebDavNode(
path=normalized_href,
name=display_name,
is_dir=is_dir,
size=int(size_text) if size_text.isdigit() else None,
etag=etag,
modified_at=modified_at,
))
nodes.sort(key=lambda item: (not item.is_dir, item.name.lower()))
return nodes

View File

@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
import app.models # noqa: F401 import app.models # noqa: F401
from app.database import Base from app.database import Base
from app.models.document import Document, DocumentChunk from app.models.document import Document, DocumentChunk
from app.models.folder import Folder
from app.models.user import User from app.models.user import User
from app.services.auth_service import get_password_hash from app.services.auth_service import get_password_hash
from app.services.document_service import DocumentService from app.services.document_service import DocumentService
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage' assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
@pytest.mark.asyncio
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
session, user = document_test_env
service = DocumentService(session)
root = Folder(user_id=user.id, name='Projects')
session.add(root)
await session.flush()
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
session.add(child)
await session.commit()
await session.refresh(child)
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
document = await service.upload_document(user.id, upload, folder_id=child.id)
file_path = Path(document.file_path)
assert file_path.name == 'system-design.md'
assert file_path.parent.name == 'Specs'
assert file_path.parent.parent.name == 'Projects'
assert file_path.exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env): async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
session, user = document_test_env session, user = document_test_env

View File

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

View File

@@ -0,0 +1,39 @@
from app.models.remote_mount import RemoteMount
from app.services.secret_service import encrypt_secret
from app.services.webdav_service import WebDavService
def test_parse_propfind_returns_sorted_nodes():
mount = RemoteMount(
user_id='user-1',
name='Docs',
mount_type='webdav',
base_url='https://example.com/dav/',
username='alice',
password_encrypted=encrypt_secret('secret'),
root_path='/knowledge',
is_active=True,
)
payload = """<?xml version="1.0" encoding="utf-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/knowledge/</d:href>
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/specs/</d:href>
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/roadmap.md</d:href>
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
</d:response>
</d:multistatus>"""
nodes = WebDavService(mount)._parse_propfind('/knowledge', payload)
assert [node.name for node in nodes] == ['specs', 'roadmap.md']
assert nodes[0].is_dir is True
assert nodes[1].is_dir is False
assert nodes[1].size == 128
assert nodes[1].etag == '"etag-1"'

View File

@@ -95,3 +95,65 @@ async def test_chat_stream_emits_error_event_when_agent_service_fails_before_str
assert response.status_code == 200 assert 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'

View File

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

View File

@@ -0,0 +1,90 @@
from httpx import ASGITransport, AsyncClient
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401
from app.database import Base, get_db
from app.main import app
from app.models.folder import Folder
from app.models.user import User
from app.routers.auth import get_current_user
from app.services.auth_service import get_password_hash
@pytest.fixture
async def folder_router_env(tmp_path, monkeypatch):
db_path = tmp_path / 'test_folders_router.db'
upload_dir = tmp_path / 'uploads'
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(
email='folders@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Folder Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir))
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield user, upload_dir, session_factory
finally:
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_create_folder_creates_matching_local_directory(folder_router_env):
user, upload_dir, _session_factory = folder_router_env
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None})
assert response.status_code == 201
folder_id = response.json()['id']
expected_path = upload_dir / user.id / 'Projects'
assert expected_path.exists()
assert expected_path.is_dir()
assert folder_id
@pytest.mark.asyncio
async def test_rename_folder_moves_local_directory(folder_router_env):
user, upload_dir, session_factory = folder_router_env
async with session_factory() as session:
folder = Folder(user_id=user.id, name='Old', parent_id=None)
session.add(folder)
await session.commit()
await session.refresh(folder)
(upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'})
assert response.status_code == 200
assert not (upload_dir / user.id / 'Old').exists()
assert (upload_dir / user.id / 'New').exists()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,165 @@
# 智慧神殿Temple升级计划索引
本目录用于存放智慧神殿Temple页面的升级规划文档。
## 文档说明
| 文件 | 说明 |
|------|------|
| `README.md` | 总览、阶段关系、实施顺序、当前状态 |
| `phase-0-current-state.md` | 当前现状、问题、目标架构 |
| `phase-1-tools-api.md` | 后端 Tools API 开发 |
| `phase-2-tools-frontend.md` | Tools Tab 前端实现 |
| `phase-3-skills-integration.md` | Skills Tab 复用集成 |
| `checklist.md` | 执行清单 |
## 推荐阅读顺序
1. 先读 `README.md`(本文)
2. 再读 `phase-0-current-state.md`
3. 再按顺序阅读 phase 1 ~ 3
4. 参考 `checklist.md` 进行任务追踪
---
## 当前总体状态2026-04-08
| Phase | 当前状态 | 说明 |
|------|------|------|
| Phase 0 | 已完成 | 现状梳理完毕,本文档 |
| Phase 1 | 待开始 | 后端 Tools API 开发 |
| Phase 2 | 待开始 | 前端 Tools Tab 实现 |
| Phase 3 | 待开始 | Skills Tab 复用集成 |
---
## 总体升级原则
1. **Tools 只读不做编辑** - 系统内置工具不允许手动修改,防止配置破坏
2. **Skills 以 DB 为 source of truth** - UI 操作 DB后端自动生成 `.md` 文件,用户不直接碰代码
3. **复用现有 Skills 页面** - 已有完整 CRUD改动成本最低
4. **MCP 暂不纳入** - 当前仅为概念性能力包,后期独立需求
5. **样式沿用现有体系** - 复用 `chatPage.css` 的深色终端风格 + `jarvis-*` CSS 变量
---
## 阶段关系图
```
Phase 0 ──────────────────────────────────────────────────────────────┐
│ 现状与目标 │
│ - Temple 页面现状分析 │
│ - Tools 系统梳理 │
│ - Skills 系统梳理 │
│ - 设计决策 │
│ 状态:已完成 │
└────────────────────────────────────────────────────────────────────┘
Phase 1 ──────────────────────────────────────────────────────────────┐
│ 后端 Tools API │
│ - GET /api/tools 接口开发 │
│ - ToolRegistry 聚合所有工具 │
│ - 聚合两套工具体系元数据 │
│ │
│ 核心文件: app/routers/tools.py │
│ 依赖: 无 │
│ 工作量: 1 天 │
└────────────────────────────────────────────────────────────────────┘
Phase 2 ──────────────────────────────────────────────────────────────┐
│ 前端 Tools Tab │
│ - useTemple.ts composable │
│ - Tools 分类树实现 │
│ - 工具详情面板 │
│ - Metrics Strip 统计行 │
│ │
│ 核心文件: frontend/src/pages/temple/ │
│ 依赖: Phase 1 │
│ 工作量: 2 天 │
└────────────────────────────────────────────────────────────────────┘
Phase 3 ──────────────────────────────────────────────────────────────┐
│ Skills Tab 复用集成 │
│ - 确认现有 Skills 页面功能完整 │
│ - 与 Temple 页面 Tab 切换联动 │
│ - 样式一致性检查 │
│ │
│ 核心文件: frontend/src/pages/temple/, frontend/src/pages/skills/ │
│ 依赖: Phase 2 │
│ 工作量: 0.5 天 │
└────────────────────────────────────────────────────────────────────┘
```
---
## 两套 Tools 体系梳理
### 注册层工具(`app/tools/`
| 工具 | Manifest | 命令数 |
|------|---------|--------|
| `file_operator` | `manifests/file_operator.yaml` | 4 |
| `task_manager` | `manifests/task_manager.yaml` | 5 |
| `web_fetch` | `manifests/web_fetch.yaml` | 2 |
| `web_search` | `manifests/web_search.yaml` | 2 |
### Agent 内置层工具(`app/agents/tools/`
| 类别 | 工具数 | 来源文件 |
|------|--------|---------|
| 文件操作 | 4 | `builtins/file_tools.py` |
| 系统命令 | 2 | `builtins/system_tools.py` |
| 开发工具 | 2 | `builtins/dev_tools.py` |
| 协作工具 | 2 | `builtins/collaboration_tools.py` |
| 知识检索 | 5 | `search.py` |
| 日程管理 | 5 | `schedule.py` |
| 任务管理 | 3 | `task.py` |
| 论坛功能 | 3 | `forum.py` |
| 时间推理 | 1 | `time_reasoning.py` |
**合计约 34 个工具命令**
---
## 设计决策记录
| 决策 | 原因 |
|------|------|
| Tools 只读不做编辑 | 系统内置工具不允许用户手动修改,防止配置破坏 |
| 不引入 MCP 管理 | 当前 MCP 仅为概念性能力包,无实际 server 连接需求,后期独立需求 |
| Skills 以 DB 为 source of truth | UI 操作 DB后端同步生成 .md 文件,用户不直接碰代码 |
| 复用现有 Skills 页面 | 已有完整 CRUD改动成本最低 |
| 按工具来源分类 | 与代码结构对应,用户可追溯工具定义位置 |
---
## 文件变更追踪
| Phase | 新增文件 | 修改文件 |
|-------|---------|---------|
| Phase 1 | `app/routers/tools.py`, `app/schemas/tools.py` | `app/main.py`(注册路由) |
| Phase 2 | `frontend/src/pages/temple/index.vue`, `templePage.css`, `composables/useTemple.ts`, `frontend/src/api/tools.ts` | `frontend/src/pages/temple/index.vue`(重写占位页) |
| Phase 3 | 无 | `frontend/src/pages/temple/index.vue`Tab 切换逻辑) |
---
## 与其他 Phase 的关系
| 相关模块 | 协作内容 |
|---------|---------|
| Skills Registry (agent-update Phase 9) | Skills 的 DB 层由 `/api/skills` 提供,文件层由 SkillRegistry 管理 |
| Tool System (tool-update T.1-T.4) | Temple 展示的 Tools 元数据来自 tool-update 建立的 manifest 系统 |
---
## 总工作量
| Phase | 工作量 |
|-------|--------|
| Phase 1 | 1 天 |
| Phase 2 | 2 天 |
| Phase 3 | 0.5 天 |
| **总计** | **3.5 天** |

View File

@@ -0,0 +1,60 @@
# 智慧神殿Temple执行清单
> 更新日期2026-04-08
> 总工作量3.5 天
---
## Phase 1后端 Tools API
| 序号 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 1.1 | 创建 `app/schemas/tools.py`,定义 Pydantic Schema | 待开始 | |
| 1.2 | 创建 `app/routers/tools.py`,实现 `GET /api/tools` | 待开始 | |
| 1.3 | 实现 ToolRegistry 工具元数据聚合 | 待开始 | 复用 `list_all()` |
| 1.4 | 实现 Agent 层工具扫描(内省 `@tool` 装饰器) | 待开始 | 扫描 `app/agents/tools/` |
| 1.5 | 实现分类分组逻辑(注册层 / Agent 层) | 待开始 | |
| 1.6 | 在 `app/main.py` 注册路由 | 待开始 | |
| 1.7 | 本地测试 `GET /api/tools` 返回正确数据 | 待开始 | |
---
## Phase 2前端 Tools Tab
| 序号 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2.1 | 创建 `frontend/src/api/tools.ts` API 客户端 | 待开始 | |
| 2.2 | 创建 `frontend/src/pages/temple/composables/useTemple.ts` | 待开始 | |
| 2.3 | 实现 Tab 切换器组件 | 待开始 | Tools / Skills 切换 |
| 2.4 | 实现 Metrics Strip 统计行 | 待开始 | |
| 2.5 | 实现分类树组件(两极结构) | 待开始 | |
| 2.6 | 实现工具列表(无选中时) | 待开始 | 卡片形式 |
| 2.7 | 实现工具详情面板 | 待开始 | 含 Commands 列表 |
| 2.8 | 创建 `templePage.css` 样式 | 待开始 | 复用 jarvis-* 变量 |
| 2.9 | 重写 `frontend/src/pages/temple/index.vue` | 待开始 | 替换占位符 |
| 2.10 | 联调后端 API数据正确渲染 | 待开始 | |
---
## Phase 3Skills Tab 复用集成
| 序号 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 3.1 | 将 Skills 页面集成到 Temple Skills Tab | 待开始 | 推荐方案 A条件渲染 |
| 3.2 | Tab 切换逻辑实现 | 待开始 | |
| 3.3 | Skills CRUD 功能验证 | 待开始 | 创建/编辑/删除/启用/禁用 |
| 3.4 | Skills Modal 和 Drawer 交互验证 | 待开始 | |
| 3.5 | Skills Tab 下 Metrics Strip 切换指标 | 待开始 | 显示 Skills 指标 |
| 3.6 | Tab 切换状态保持验证 | 待开始 | 不丢失选中状态 |
---
## 验收标准
- [ ] `GET /api/tools` 返回 200响应结构正确
- [ ] Temple 页面加载无报错
- [ ] Tools Tab 显示所有工具分类
- [ ] 点击工具有详情Commands 列表完整)
- [ ] Skills Tab 下 Skills CRUD 全部正常
- [ ] 样式与 Jarvis 整体风格一致
- [ ] 无前端 console.error

View File

@@ -0,0 +1,171 @@
# Phase 0智慧神殿现状与目标
日期2026-04-08
状态:已完成
---
## 1. 本阶段目的
本文件用于统一背景认知,明确:
- Temple 页面当前处于什么状态
- 主要短板是什么
- 为什么要升级
- 升级后的目标形态是什么
---
## 2. 当前 Temple 页面状态
### 2.1 现有实现
`frontend/src/pages/temple/index.vue` 是一个**空白占位页**
```vue
<script setup lang="ts">
// 智慧神殿 - Temple of Wisdom
</script>
<template>
<div class="temple-page">
<div class="page-header">
<h1> 智慧神殿</h1>
<p class="subtitle">深邃智慧永恒传承</p>
</div>
<div class="page-content">
<div class="placeholder-content">
<div class="temple-icon">🏛</div>
<p>智慧神殿 - 敬请期待</p>
</div>
</div>
</div>
</template>
```
### 2.2 触发入口
聊天输入框上方三个按钮之一(`◈`),跳转到 `/temple`
```html
<!-- frontend/src/pages/chat/index.vue -->
<div class="top-buttons-row">
<button class="top-action-btn" @click="$router.push('/temple')" title="Temple">
<span class="btn-icon temple-icon"></span>
</button>
<button class="top-action-btn" @click="$router.push('/knowledge')" title="Knowledge">
<span class="btn-icon knowledge-icon"></span>
</button>
<button class="top-action-btn" @click="$router.push('/war-room')" title="War Room">
<span class="btn-icon war-icon"></span>
</button>
</div>
```
---
## 3. 当前系统现状
### 3.1 Tools 系统(两套并存)
#### A. 工具注册层(`app/tools/`
已建立 manifest 驱动的工具注册体系:
```
app/tools/
├── manifests/ # YAML manifest 定义
│ ├── file_operator.yaml # 4 commands: read_file, write_file, list_directory, search_files
│ ├── task_manager.yaml # 5 commands: create_task, list_tasks, get_task, complete_task, fail_task
│ ├── web_fetch.yaml # 2 commands: fetch, screenshot
│ └── web_search.yaml # 2 commands: search, deep_search
├── registry.py # ToolRegistry 动态注册中心
├── implementations/ # 工具 Python 实现
├── permissions.py # 权限控制
├── hooks/ # Hook 系统(审计日志、安全扫描、危险确认)
└── schemas/ # Pydantic Schema
```
#### B. Agent 工具层(`app/agents/tools/`
LangChain `@tool` 装饰器定义的 Agent 可用工具:
| 类别 | 工具 | 源文件 |
|------|------|--------|
| 文件操作 | `glob`, `grep`, `read_file`, `write_file` | `builtins/file_tools.py` |
| 系统命令 | `bash`, `powershell` | `builtins/system_tools.py` |
| 开发工具 | `git`, `lsp_tools` | `builtins/dev_tools.py` |
| 协作工具 | `team_agent`, `task_broadcast` | `builtins/collaboration_tools.py` |
| 知识检索 | `search_knowledge`, `get_knowledge_graph_context`, `build_knowledge_graph`, `hybrid_search`, `web_search` | `search.py` |
| 日程管理 | `get_schedule_day`, `create_todo`, `create_schedule_task`, `create_reminder`, `create_goal` | `schedule.py` |
| 任务管理 | `get_tasks`, `create_task`, `update_task_status` | `task.py` |
| 论坛功能 | `get_forum_posts`, `create_forum_post`, `scan_forum_for_instructions` | `forum.py` |
| 时间推理 | `resolve_time_expression` | `time_reasoning.py` |
### 3.2 Skills 系统
#### A. DB 层
已有完整 CRUD
- 路由:`/api/skills`
- 字段:`name`, `description`, `instructions`, `agent_type`, `tools`, `visibility`, `is_builtin`, `is_active`
- Agent types`general`, `schedule_planner`, `executor`, `librarian`, `analyst`
- Visibility`private`, `team`, `market`
#### B. 文件层
`SkillRegistry` 加载 `.md` 文件供 Agent 运行时使用。
加载器:
- `MCPSkillLoader` - MCP 能力包加载
- `LocalSkillLoader` - 本地 `.md` 文件加载
- `PluginLoader` - 插件式加载
### 3.3 当前问题
| 问题 | 影响 |
|------|------|
| Temple 页面是空白占位页 | 三个按钮入口之一完全无功能 |
| Tools 无统一展示入口 | 用户无法看到系统有哪些可用工具 |
| Tools 散落在两套体系 | manifest 层 + agent 层,用户无感知 |
| Skills 页面独立在 `/skills` | 工具和技能没有统一管理入口 |
---
## 4. 目标架构
```
┌─────────────────────────────────────────────────────────────┐
│ /temple │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ [◈ 智慧神殿] [Tools] [Skills] │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TOTAL: 30 ACTIVE: 28 AGENTS: 5 (Metrics) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────┐ ┌─────────────────────────────────┐ │
│ │ [分类树] │ │ [工具详情] │ │
│ │ │ │ │ │
│ │ ▼ 注册层 │ │ file_operator │ │
│ │ 文件操作 │ │ 描述: 强大的文件系统操作工具 │ │
│ │ 任务管理 │ │ 命令: 4 个 │ │
│ │ ▼ Agent层 │ │ 调用: 1,234 次 错误率: 0.2% │ │
│ │ 知识检索 │ │ │ │
│ │ 日程管理 │ │ [Commands] │ │
│ │ 任务管理 │ │ • read_file │ │
│ │ 论坛功能 │ │ • write_file │ │
│ │ 时间推理 │ │ • list_directory │ │
│ └────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 5. 本阶段产出要求
- [x] 团队对 Temple 当前状态和目标方向达成一致
- [x] Tools 系统两套并存的现状已梳理清楚
- [x] Skills 系统现有架构已梳理清楚
- [x] 后续 phase 文档能够在这个认知基础上展开

View File

@@ -0,0 +1,135 @@
# Phase 1后端 Tools API 开发
日期2026-04-08
状态:待开始
---
## 1. 本阶段目的
开发 `GET /api/tools` 接口,聚合两套工具体系的元数据,为前端 Tools Tab 提供数据源。
---
## 2. 核心文件
| 文件 | 作用 |
|------|------|
| `app/routers/tools.py` | 新建Tools 路由 |
| `app/schemas/tools.py` | 新建Tools API Pydantic Schema |
---
## 3. API 设计
### 3.1 接口
```
GET /api/tools
```
### 3.2 响应结构
```python
class ToolCommand(BaseModel):
name: str
description: str
parameters: dict # JSON Schema
class ToolStats(BaseModel):
call_count: int
error_count: int
total_duration_ms: int
avg_duration_ms: int
error_rate: float
class ToolCategory(BaseModel):
name: str # 显示用中文分类名
source: str # "manifest" | "agent"
tools: list[ToolInfo]
class ToolInfo(BaseModel):
name: str
display_name: str
description: str
category: str
tags: list[str]
enabled: bool
source: str # "manifest" | "agent"
commands: list[ToolCommand]
stats: ToolStats | None
class ToolsResponse(BaseModel):
categories: list[ToolCategory]
summary: dict:
total: int
active: int
by_source: dict
```
### 3.3 分类结构
按工具来源分为两大类:
**注册层source: "manifest"**
| Category Name | 来源 |
|--------------|------|
| `文件操作` | `manifests/file_operator.yaml` |
| `任务管理` | `manifests/task_manager.yaml` |
| `网页抓取` | `manifests/web_fetch.yaml` |
| `联网搜索` | `manifests/web_search.yaml` |
**Agent 层source: "agent"**
| Category Name | 来源 |
|--------------|------|
| `文件工具` | `builtins/file_tools.py` |
| `系统命令` | `builtins/system_tools.py` |
| `开发工具` | `builtins/dev_tools.py` |
| `协作工具` | `builtins/collaboration_tools.py` |
| `知识检索` | `search.py` |
| `日程管理` | `schedule.py` |
| `任务管理` | `task.py` |
| `论坛功能` | `forum.py` |
| `时间推理` | `time_reasoning.py` |
---
## 4. 实现逻辑
### 4.1 数据聚合流程
```
1. 从 ToolRegistry.list_all() 获取注册层工具元数据
2. 扫描 app/agents/tools/ 下所有 @tool 装饰器,获取 Agent 层工具
3. 合并两套数据,按 category 分组
4. 调用 ToolRegistry.get_stats() 获取统计数据
5. 返回聚合后的 categories + summary
```
### 4.2 Agent 层工具扫描
通过内省 `app/agents/tools/` 目录下所有 `@tool` 装饰的函数,提取:
- `__name__` → tool name
- `__doc__` → description
- `__annotations__` → parameters schema
### 4.3 注册路由
`app/main.py` 中注册新路由:
```python
from app.routers import tools as tools_router
app.include_router(tools_router.router, prefix="/api", tags=["tools"])
```
---
## 5. 产出要求
- [x] `GET /api/tools` 接口可调用,返回完整工具列表
- [x] 两套工具体系元数据正确聚合
- [x] 统计数据(调用次数、错误率)正确返回
- [x] 按 category 分组source 字段区分来源

View File

@@ -0,0 +1,167 @@
# Phase 2前端 Tools Tab 实现
日期2026-04-08
状态:待开始
---
## 1. 本阶段目的
实现 Temple 页面的 Tools Tab包括分类树 + 详情面板 + Metrics Strip。
---
## 2. 核心文件
| 文件 | 作用 |
|------|------|
| `frontend/src/api/tools.ts` | 新建Tools API 客户端 |
| `frontend/src/pages/temple/composables/useTemple.ts` | 新建Tab/Skills 逻辑 |
| `frontend/src/pages/temple/index.vue` | 重写主页面(替换占位符) |
| `frontend/src/pages/temple/templePage.css` | 新建,样式 |
---
## 3. 页面布局
```
┌─────────────────────────────────────────────────────────────┐
│ [◈ 智慧神殿] [Tools] [Skills] ← Tab 切换器 │
├─────────────────────────────────────────────────────────────┤
│ TOTAL: 30 │ ACTIVE: 28 │ AGENTS: 5 ← Metrics Strip │
├──────────────────────────┬──────────────────────────────────┤
│ │ │
│ [分类树] │ [工具详情] │
│ │ │
│ ▼ 注册层 │ file_operator │
│ 文件操作 │ ──────────── │
│ 任务管理 │ 描述: 强大的文件系统操作工具 │
│ 网页抓取 │ 命令: 4 个 │
│ 联网搜索 │ 标签: file, system, essential │
│ ▼ Agent层 │ 状态: 启用 │
│ 文件工具 │ 调用: 1,234 次 │
│ 系统命令 │ 错误率: 0.2% │
│ 开发工具 │ 平均耗时: 150ms │
│ 协作工具 │ │
│ 知识检索 │ [Commands] │
│ 日程管理 │ ─────────────────────────── │
│ 任务管理 │ read_file │
│ 论坛功能 │ ─────────────────────────── │
│ 时间推理 │ write_file │
│ │ ─────────────────────────── │
│ │ list_directory │
│ │ ─────────────────────────── │
│ │ search_files │
└──────────────────────────┴──────────────────────────────────┘
```
---
## 4. 组件说明
### 4.1 Tab 切换器
两个 Tab`Tools` | `Skills`
- `Tools` → 本 phase 实现
- `Skills` → Phase 3复用现有页面
### 4.2 Metrics Strip
三个统计指标卡片:
| 指标 | 说明 |
|------|------|
| `TOTAL` | 系统工具总数(所有工具的 commands 总数) |
| `ACTIVE` | 启用中的工具数 |
| `AGENTS` | 工具绑定的 Agent 类型数(固定 5 |
### 4.3 分类树
- 两级结构:大类(注册层 / Agent 层)→ 具体分类
- 点击分类 → 右侧显示该分类下的工具列表
- 点击工具 → 右侧显示工具详情
### 4.4 工具详情面板
当无工具选中时:显示分类下的工具列表(卡片形式)
当有工具选中时:显示工具详情
详情内容:
- **Name / Display Name**
- **Description**
- **Category / Tags**
- **Enabled status**
- **Stats**: call_count, error_rate, avg_duration_ms
- **Commands**: 每个 command 的 name + description只读
---
## 5. useTemple.ts 接口设计
```typescript
// useTemple.ts
export function useTemple() {
// State
const activeTab = ref<'tools' | 'skills'>('tools')
const categories = ref<ToolCategory[]>([])
const selectedCategory = ref<string | null>(null)
const selectedTool = ref<ToolInfo | null>(null)
const loading = ref(false)
// Computed
const summary = computed(() => { ... })
const currentCategoryTools = computed(() => { ... })
// Actions
async function fetchTools() { ... }
function selectCategory(name: string) { ... }
function selectTool(tool: ToolInfo) { ... }
return {
activeTab,
categories,
selectedCategory,
selectedTool,
loading,
summary,
currentCategoryTools,
fetchTools,
selectCategory,
selectTool,
}
}
```
---
## 6. 样式规范
沿用 Jarvis 现有风格:
```css
/* templePage.css */
.temple-page {
/* 复用 jarvis-* CSS 变量 */
background: var(--bg-primary);
color: var(--text-primary);
}
.metric-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.category-tree {
/* 深色终端风格 */
}
```
---
## 7. 产出要求
- [x] Tab 切换器正常切换 Tools / Skills
- [x] Metrics Strip 正确显示统计数据
- [x] 分类树正确渲染,展开/收起正常
- [x] 点击工具有详情面板Commands 列表完整
- [x] 样式与 Jarvis 整体风格一致

View File

@@ -0,0 +1,88 @@
# Phase 3Skills Tab 复用集成
日期2026-04-08
状态:待开始
---
## 1. 本阶段目的
将现有的 `/skills` 页面完整嵌入 Temple 页面的 Skills Tab实现统一入口。
---
## 2. 核心文件
| 文件 | 作用 |
|------|------|
| `frontend/src/pages/skills/index.vue` | 已有Skills 完整页面 |
| `frontend/src/pages/skills/composables/useSkillsPage.ts` | 已有Skills 逻辑 |
| `frontend/src/api/skill.ts` | 已有Skills API 客户端 |
---
## 3. 集成方式
### 3.1 方案选择
**方案 A推荐Tab 内条件渲染**
`Temple/index.vue` 中使用 `v-if` 切换:
```vue
<div v-if="activeTab === 'skills'">
<!-- Skills 页面内容内联或引用子组件 -->
</div>
```
优点:单一页面,状态共享简单
缺点Skills 页面较大,代码集中
**方案 B路由嵌套**
```vue
// Temple/index.vue
<router-view />
```
`skills/` 路由加 `parent: temple`
优点:页面分离,代码清晰
缺点:需要改路由配置
**推荐方案 A**改动最小Skills 页面代码以内联形式放入 Temple。
### 3.2 Tab 切换逻辑
```typescript
function switchTab(tab: 'tools' | 'skills') {
activeTab.value = tab
if (tab === 'skills') {
// Skills 页面初始化(如果需要)
}
}
```
---
## 4. 样式调整
Skills 页面样式独立在 `skillsPage.css`,切换 Tab 时保留其样式上下文。
---
## 5. 注意事项
- Skills 页面的 Modal创建/编辑)需要在 Tab 切换后仍可正常弹出
- Skills 页面的 API 调用(`skillApi.list()`, `skillApi.create()` 等)保持不变
- Metrics Strip 在 Skills Tab 下显示不同的指标TOTAL / ACTIVE / UPTIME
---
## 6. 产出要求
- [x] Skills Tab 点击后正确切换到 Skills 页面
- [x] Skills 的 CRUD创建/编辑/删除/启用/禁用)功能正常
- [x] Skills 的 MCP Panel 仍可正常打开
- [x] Skills 页面的 Modal、Drawer 等交互正常
- [x] Tab 切换不丢失状态

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@
"element-plus": "^2.13.6", "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",

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

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