feat: enhance agent orchestration, knowledge flow and UI refinements

This commit is contained in:
2026-03-29 20:31:13 +08:00
parent d85cb9cf35
commit e0fe3ca623
301 changed files with 1197804 additions and 7863 deletions

View File

@@ -6,6 +6,9 @@ from app.routers.forum import router as forum_router
from app.routers.graph import router as graph_router
from app.routers.agent import router as agent_router
from app.routers.todo import router as todo_router
from app.routers.reminder import router as reminder_router
from app.routers.goal import router as goal_router
from app.routers.schedule_center import router as schedule_center_router
from app.routers.settings import router as settings_router
from app.routers.folder import router as folder_router
from app.routers.skill import router as skill_router

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.agent import Agent
from app.models.skill import Skill
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.agent import AgentCreate, AgentOut, AgentStats, AgentConfigUpdate, AgentConfigOut
@@ -13,9 +14,9 @@ _agent_call_counts: dict[str, int] = {}
_agent_current_tasks: dict[str, str | None] = {}
_agent_statuses: dict[str, str] = {}
DEFAULT_AGENT_ROLES = ["master", "planner", "executor", "librarian", "analyst"]
DEFAULT_AGENT_ROLES = ["master", "schedule_planner", "executor", "librarian", "analyst"]
SUB_COMMANDERS_BY_ROLE = {
"planner": ["planner_scope", "planner_steps"],
"schedule_planner": ["schedule_analysis", "schedule_planning"],
"executor": ["executor_tasks", "executor_forum"],
"librarian": ["librarian_retrieval", "librarian_graph"],
"analyst": ["analyst_progress", "analyst_insights"],
@@ -88,10 +89,10 @@ async def get_agent_config(
agent = result.scalar_one_or_none()
if not agent:
from app.agents.prompts import MASTER_SYSTEM_PROMPT, PLANNER_SYSTEM_PROMPT, EXECUTOR_SYSTEM_PROMPT, LIBRARIAN_SYSTEM_PROMPT, ANALYST_SYSTEM_PROMPT
from app.agents.prompts import MASTER_SYSTEM_PROMPT, SCHEDULE_PLANNER_SYSTEM_PROMPT, EXECUTOR_SYSTEM_PROMPT, LIBRARIAN_SYSTEM_PROMPT, ANALYST_SYSTEM_PROMPT
defaults = {
"master": ("JARVIS", "主控制核心", MASTER_SYSTEM_PROMPT),
"planner": ("PLANNER", "规划专家", PLANNER_SYSTEM_PROMPT),
"schedule_planner": ("SCHEDULE PLANNER", "日程规划师", SCHEDULE_PLANNER_SYSTEM_PROMPT),
"executor": ("EXECUTOR", "执行专家", EXECUTOR_SYSTEM_PROMPT),
"librarian": ("LIBRARIAN", "知识管理员", LIBRARIAN_SYSTEM_PROMPT),
"analyst": ("ANALYST", "数据分析师", ANALYST_SYSTEM_PROMPT),
@@ -107,6 +108,7 @@ async def get_agent_config(
system_prompt=prompt,
enabled=True,
is_active=True,
selected_skill_ids=[],
)
return AgentConfigOut(
id=agent.role,
@@ -116,6 +118,7 @@ async def get_agent_config(
system_prompt=agent.system_prompt,
enabled=agent.is_active,
is_active=agent.is_active,
selected_skill_ids=agent.selected_skill_ids or [],
)
@@ -141,6 +144,19 @@ async def update_agent_config(
if data.enabled is not None:
agent.is_active = data.enabled
_agent_statuses[agent_id] = "disabled" if not data.enabled else "idle"
if data.selected_skill_ids is not None:
if data.selected_skill_ids:
result = await db.execute(
select(Skill.id).where(
Skill.id.in_(data.selected_skill_ids),
Skill.owner_id == current_user.id,
)
)
allowed_skill_ids = set(result.scalars().all())
invalid_skill_ids = [skill_id for skill_id in data.selected_skill_ids if skill_id not in allowed_skill_ids]
if invalid_skill_ids:
raise HTTPException(status_code=400, detail="存在无效的技能绑定")
agent.selected_skill_ids = data.selected_skill_ids
await db.commit()
await db.refresh(agent)
@@ -152,6 +168,7 @@ async def update_agent_config(
system_prompt=agent.system_prompt,
enabled=agent.is_active,
is_active=agent.is_active,
selected_skill_ids=agent.selected_skill_ids or [],
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy.exc import IntegrityError
from app.database import get_db
from app.models.user import User
from app.schemas.auth import UserCreate, UserOut, Token
from app.services.admin_bootstrap_service import ensure_builtin_skills
from app.services.auth_service import verify_password, get_password_hash, create_access_token, decode_token
from app.config import settings
@@ -58,6 +59,7 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
await db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名或邮箱已被注册")
await db.refresh(user)
await ensure_builtin_skills(db)
return user
@@ -97,10 +99,12 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSessi
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名、邮箱或密码错误")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已被禁用")
await ensure_builtin_skills(db)
access_token = create_access_token(data={"sub": user.id})
return Token(access_token=access_token)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
async def get_me(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
await ensure_builtin_skills(db)
return current_user

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@@ -92,13 +93,16 @@ async def chat(
):
"""简单版对话(非流式)"""
agent_svc = AgentService(db)
conv_id, msg_id, content, model_name = await agent_svc.chat_simple(
user_id=current_user.id,
message=data.message,
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
)
try:
conv_id, msg_id, content, model_name = await agent_svc.chat_simple(
user_id=current_user.id,
message=data.message,
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# 更新对话消息计数
result = await db.execute(select(Conversation).where(Conversation.id == conv_id))
@@ -126,13 +130,17 @@ async def chat_stream(
agent_svc = AgentService(db)
async def stream_generator():
conv_id, msg_id, stream = await agent_svc.chat(
user_id=current_user.id,
message=data.message,
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
)
try:
conv_id, msg_id, stream = await agent_svc.chat(
user_id=current_user.id,
message=data.message,
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
)
except ValueError as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
return
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"

View File

@@ -0,0 +1,92 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.goal import Goal
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.goal import GoalCreate, GoalListOut, GoalOut, GoalUpdate
router = APIRouter(prefix="/api/goals", tags=["目标"])
@router.get("", response_model=GoalListOut)
async def list_goals(
date_str: str = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
target_date = date.fromisoformat(date_str).isoformat()
query = (
select(Goal)
.where(Goal.user_id == current_user.id)
.where(Goal.goal_date == target_date)
.order_by(Goal.created_at.desc())
)
items = (await db.execute(query)).scalars().all()
return GoalListOut(items=items)
@router.post("", response_model=GoalOut, status_code=201)
async def create_goal(
data: GoalCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
goal = Goal(
user_id=current_user.id,
title=data.title,
note=data.note,
goal_date=data.goal_date.isoformat(),
status=data.status,
)
db.add(goal)
await db.commit()
await db.refresh(goal)
return goal
@router.patch("/{goal_id}", response_model=GoalOut)
async def update_goal(
goal_id: str,
data: GoalUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Goal).where(Goal.id == goal_id, Goal.user_id == current_user.id)
)
goal = result.scalar_one_or_none()
if not goal:
raise HTTPException(status_code=404, detail="目标不存在")
payload = data.model_dump(exclude_none=True)
if "goal_date" in payload:
payload["goal_date"] = payload["goal_date"].isoformat()
for field, value in payload.items():
setattr(goal, field, value)
await db.commit()
await db.refresh(goal)
return goal
@router.delete("/{goal_id}", status_code=204)
async def delete_goal(
goal_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Goal).where(Goal.id == goal_id, Goal.user_id == current_user.id)
)
goal = result.scalar_one_or_none()
if not goal:
raise HTTPException(status_code=404, detail="目标不存在")
await db.delete(goal)
await db.commit()

View File

@@ -0,0 +1,90 @@
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.reminder import Reminder
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.reminder import ReminderCreate, ReminderListOut, ReminderOut, ReminderUpdate
router = APIRouter(prefix="/api/reminders", tags=["提醒"])
@router.get("", response_model=ReminderListOut)
async def list_reminders(
date_str: str = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
target_date = date.fromisoformat(date_str)
start = datetime.combine(target_date, datetime.min.time())
end = datetime.combine(target_date, datetime.max.time())
query = (
select(Reminder)
.where(Reminder.user_id == current_user.id)
.where(Reminder.reminder_at >= start)
.where(Reminder.reminder_at <= end)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)
items = (await db.execute(query)).scalars().all()
return ReminderListOut(items=items)
@router.post("", response_model=ReminderOut, status_code=201)
async def create_reminder(
data: ReminderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
reminder = Reminder(
user_id=current_user.id,
title=data.title,
note=data.note,
reminder_at=data.reminder_at,
)
db.add(reminder)
await db.commit()
await db.refresh(reminder)
return reminder
@router.patch("/{reminder_id}", response_model=ReminderOut)
async def update_reminder(
reminder_id: str,
data: ReminderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Reminder).where(Reminder.id == reminder_id, Reminder.user_id == current_user.id)
)
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="提醒不存在")
for field, value in data.model_dump(exclude_none=True).items():
setattr(reminder, field, value)
await db.commit()
await db.refresh(reminder)
return reminder
@router.delete("/{reminder_id}", status_code=204)
async def delete_reminder(
reminder_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Reminder).where(Reminder.id == reminder_id, Reminder.user_id == current_user.id)
)
reminder = result.scalar_one_or_none()
if not reminder:
raise HTTPException(status_code=404, detail="提醒不存在")
await db.delete(reminder)
await db.commit()

View File

@@ -0,0 +1,160 @@
from calendar import monthrange
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority
from app.models.todo import DailyTodo
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.schedule_center import (
ScheduleCenterDateOut,
ScheduleCenterDaySummary,
ScheduleCenterMonthOut,
)
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
def _build_summary(
target_date: str,
todos: list[DailyTodo],
tasks: list[Task],
reminders: list[Reminder],
goals: list[Goal],
) -> ScheduleCenterDaySummary:
return ScheduleCenterDaySummary(
date=target_date,
todo_total=len(todos),
todo_completed=sum(1 for item in todos if item.is_completed),
task_due_total=len(tasks),
high_priority_total=sum(1 for item in tasks if item.priority in {TaskPriority.HIGH, TaskPriority.URGENT}),
reminder_total=len(reminders),
goal_total=len(goals),
)
@router.get("/month", response_model=ScheduleCenterMonthOut)
async def get_month_schedule(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
month_start = date(year, month, 1)
days_in_month = monthrange(month_start.year, month_start.month)[1]
start_key = month_start.isoformat()
end_key = month_start.replace(day=days_in_month).isoformat()
start_dt = datetime.combine(month_start, datetime.min.time())
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
todos = (await db.execute(
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
)).scalars().all()
tasks = (await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
)).scalars().all()
reminders = (await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
)).scalars().all()
goals = (await db.execute(
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
)).scalars().all()
todo_map: dict[str, list[DailyTodo]] = {}
for item in todos:
todo_map.setdefault(item.todo_date, []).append(item)
task_map: dict[str, list[Task]] = {}
for item in tasks:
key = item.due_date.date().isoformat()
task_map.setdefault(key, []).append(item)
reminder_map: dict[str, list[Reminder]] = {}
for item in reminders:
key = item.reminder_at.date().isoformat()
reminder_map.setdefault(key, []).append(item)
goal_map: dict[str, list[Goal]] = {}
for item in goals:
goal_map.setdefault(item.goal_date, []).append(item)
days = []
for day in range(1, days_in_month + 1):
date_key = month_start.replace(day=day).isoformat()
days.append(_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
))
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
@router.get("/date", response_model=ScheduleCenterDateOut)
async def get_date_schedule(
date_str: date = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
target_date = date_str
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = datetime.combine(target_date, datetime.max.time())
date_key = target_date.isoformat()
todos = (await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)).scalars().all()
tasks = (await db.execute(
select(Task)
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
.order_by(Task.created_at.desc())
)).scalars().all()
reminders = (await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)).scalars().all()
goals = (await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)).scalars().all()
summary = _build_summary(date_key, todos, tasks, reminders, goals)
return ScheduleCenterDateOut(
date=date_key,
todos=todos,
tasks=tasks,
reminders=reminders,
goals=goals,
summary=summary,
generated_at=datetime.now(UTC),
)

View File

@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.skill import Skill
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.skill import SkillCreate, SkillOut, SkillUpdate
from app.services.admin_bootstrap_service import ensure_builtin_skills
from app.services.skill_service import SkillService
router = APIRouter(prefix="/api/skills", tags=["Skill"])
@@ -37,13 +39,23 @@ async def create_skill(
@router.get("", response_model=list[SkillOut])
async def list_skills(
agent_type: str | None = Query(default=None),
visibility: str | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Skill).where(Skill.owner_id == current_user.id).order_by(Skill.created_at.desc())
)
return result.scalars().all()
service = SkillService(db)
return await service.list_for_user(current_user.id, agent_type=agent_type, visibility=visibility)
@router.post("/bootstrap-builtin", response_model=list[SkillOut])
async def bootstrap_builtin_skills(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await ensure_builtin_skills(db, preferred_owner_id=current_user.id)
service = SkillService(db)
return await service.list_for_user(current_user.id)
@router.get("/{skill_id}", response_model=SkillOut)

View File

@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.database import get_db
from app.models.task import Task, TaskStatus
from app.models.user import User
@@ -13,12 +15,28 @@ router = APIRouter(prefix="/api/tasks", tags=["看板"])
@router.get("", response_model=list[TaskOut])
async def list_tasks(
status: TaskStatus | None = None,
due_date: date | None = Query(default=None),
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Task).where(Task.user_id == current_user.id)
if status:
query = query.where(Task.status == status)
if due_date:
start = datetime.combine(due_date, datetime.min.time())
end = datetime.combine(due_date, datetime.max.time())
query = query.where(Task.due_date.is_not(None), Task.due_date >= start, Task.due_date <= end)
else:
start = datetime.combine(date_from, datetime.min.time()) if date_from else None
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
if start and end and start > end:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
if start is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
if end is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
query = query.order_by(desc(Task.created_at))
result = await db.execute(query)
return result.scalars().all()
@@ -64,10 +82,10 @@ async def update_task(
if field == "tags":
setattr(task, field, json.dumps(value))
elif field == "status" and value == TaskStatus.DONE:
from datetime import UTC, datetime
task.completed_at = datetime.now(UTC)
setattr(task, field, value)
else:
elif field == "status":
task.completed_at = None
setattr(task, field, value)
await db.commit()

View File

@@ -1,7 +1,8 @@
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import date
from app.database import get_db
from app.models.todo import DailyTodo, TodoSource
from app.models.user import User
@@ -52,7 +53,7 @@ async def create_todo(
user_id=current_user.id,
title=data.title,
source=TodoSource.MANUAL,
todo_date=date.today().isoformat(),
todo_date=(data.todo_date or date.today()).isoformat(),
)
db.add(todo)
await db.commit()
@@ -74,14 +75,11 @@ async def update_todo(
if not todo:
raise HTTPException(status_code=404, detail="待办不存在")
# 历史日期不允许修改
if todo.todo_date != date.today().isoformat():
raise HTTPException(status_code=403, detail="历史待办不可修改")
if data.title is not None:
todo.title = data.title
if data.todo_date is not None:
todo.todo_date = data.todo_date.isoformat()
if data.is_completed is not None:
from datetime import UTC, datetime
todo.is_completed = data.is_completed
todo.completed_at = datetime.now(UTC) if data.is_completed else None
@@ -102,9 +100,6 @@ async def delete_todo(
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="待办不存在")
if todo.todo_date != date.today().isoformat():
raise HTTPException(status_code=403, detail="历史待办不可删除")
await db.delete(todo)
await db.commit()