feat(agents): Phase 8.4-10.5 built-in plugins, bundled skills, coordinator
This commit is contained in:
220
backend/app/agents/background/executor.py
Normal file
220
backend/app/agents/background/executor.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Background task executor - Phase 10.4"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .manager import (
|
||||
BackgroundTask,
|
||||
BackgroundTaskManager,
|
||||
BackgroundTaskStatus,
|
||||
get_background_task_manager,
|
||||
)
|
||||
|
||||
|
||||
class BackgroundExecutor:
|
||||
"""Executes background tasks with error handling and result storage.
|
||||
|
||||
Provides methods to execute tasks synchronously or asynchronously,
|
||||
with full integration into BackgroundTaskManager for tracking.
|
||||
"""
|
||||
|
||||
def __init__(self, task_manager: BackgroundTaskManager | None = None):
|
||||
"""Initialize the executor.
|
||||
|
||||
Args:
|
||||
task_manager: Optional BackgroundTaskManager instance.
|
||||
If not provided, uses the global singleton.
|
||||
"""
|
||||
self._task_manager = task_manager or get_background_task_manager()
|
||||
self._executors: dict[str, asyncio.Task] = {}
|
||||
|
||||
async def execute_task(
|
||||
self,
|
||||
task_id: str,
|
||||
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> BackgroundTask:
|
||||
"""Execute a specific task by ID.
|
||||
|
||||
Args:
|
||||
task_id: Unique task identifier
|
||||
func: Async function to execute
|
||||
*args: Positional arguments for the function
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
The BackgroundTask with result or error
|
||||
"""
|
||||
# Get or create task record
|
||||
task = self._task_manager.get_task_status(task_id)
|
||||
if task is None:
|
||||
# Create a new task record if one doesn't exist
|
||||
task = BackgroundTask(
|
||||
id=task_id,
|
||||
name=f"executor_task_{task_id}",
|
||||
status=BackgroundTaskStatus.PENDING,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
self._task_manager._tasks[task_id] = task
|
||||
|
||||
# Update status to running
|
||||
task.status = BackgroundTaskStatus.RUNNING
|
||||
task.started_at = datetime.now()
|
||||
|
||||
try:
|
||||
# Execute the async function
|
||||
result = await func(*args, **kwargs)
|
||||
task.status = BackgroundTaskStatus.COMPLETED
|
||||
task.result = result
|
||||
except Exception as e:
|
||||
task.status = BackgroundTaskStatus.FAILED
|
||||
task.error = f"{type(e).__name__}: {str(e)}"
|
||||
task.result = None
|
||||
finally:
|
||||
task.completed_at = datetime.now()
|
||||
# Clean up executor reference
|
||||
if task_id in self._executors:
|
||||
del self._executors[task_id]
|
||||
|
||||
return task
|
||||
|
||||
async def execute_async(
|
||||
self,
|
||||
task_id: str,
|
||||
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute a task asynchronously in the background.
|
||||
|
||||
Args:
|
||||
task_id: Unique task identifier
|
||||
func: Async function to execute
|
||||
*args: Positional arguments for the function
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
The task ID
|
||||
"""
|
||||
# Create task record if it doesn't exist
|
||||
if self._task_manager.get_task_status(task_id) is None:
|
||||
self._task_manager._tasks[task_id] = BackgroundTask(
|
||||
id=task_id,
|
||||
name=f"async_task_{task_id}",
|
||||
status=BackgroundTaskStatus.PENDING,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
# Create and store the asyncio task
|
||||
async_task = asyncio.create_task(self.execute_task(task_id, func, *args, **kwargs))
|
||||
self._executors[task_id] = async_task
|
||||
|
||||
return task_id
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""Cancel a running task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found or not running
|
||||
"""
|
||||
if task_id not in self._executors:
|
||||
return False
|
||||
|
||||
self._executors[task_id].cancel()
|
||||
del self._executors[task_id]
|
||||
|
||||
# Update task status
|
||||
task = self._task_manager.get_task_status(task_id)
|
||||
if task:
|
||||
task.status = BackgroundTaskStatus.CANCELLED
|
||||
task.completed_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_task_result(self, task_id: str) -> Any:
|
||||
"""Get the result of a completed task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID
|
||||
|
||||
Returns:
|
||||
The task result or None if not found/not completed
|
||||
"""
|
||||
task = self._task_manager.get_task_status(task_id)
|
||||
if task and task.status == BackgroundTaskStatus.COMPLETED:
|
||||
return task.result
|
||||
return None
|
||||
|
||||
def get_task_error(self, task_id: str) -> str | None:
|
||||
"""Get the error of a failed task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID
|
||||
|
||||
Returns:
|
||||
The error message or None if not found/not failed
|
||||
"""
|
||||
task = self._task_manager.get_task_status(task_id)
|
||||
if task and task.status == BackgroundTaskStatus.FAILED:
|
||||
return task.error
|
||||
return None
|
||||
|
||||
def is_task_running(self, task_id: str) -> bool:
|
||||
"""Check if a task is currently running.
|
||||
|
||||
Args:
|
||||
task_id: The task ID
|
||||
|
||||
Returns:
|
||||
True if running, False otherwise
|
||||
"""
|
||||
return task_id in self._executors
|
||||
|
||||
def wait_for_task(self, task_id: str, timeout: float | None = None) -> BackgroundTask:
|
||||
"""Wait for a task to complete.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to wait for
|
||||
timeout: Optional timeout in seconds
|
||||
|
||||
Returns:
|
||||
The completed BackgroundTask
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If task doesn't complete within timeout
|
||||
asyncio.CancelledError: If task is cancelled
|
||||
"""
|
||||
if task_id not in self._executors:
|
||||
task = self._task_manager.get_task_status(task_id)
|
||||
if task:
|
||||
return task
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
async def wait_task() -> BackgroundTask:
|
||||
await self._executors[task_id]
|
||||
return self._task_manager.get_task_status(task_id)
|
||||
|
||||
return asyncio.run_until_complete(asyncio.wait_for(wait_task(), timeout=timeout))
|
||||
|
||||
@property
|
||||
def task_manager(self) -> BackgroundTaskManager:
|
||||
"""Get the underlying task manager."""
|
||||
return self._task_manager
|
||||
|
||||
|
||||
# Global executor instance
|
||||
_executor: BackgroundExecutor | None = None
|
||||
|
||||
|
||||
def get_background_executor() -> BackgroundExecutor:
|
||||
"""Get the global BackgroundExecutor instance."""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = BackgroundExecutor()
|
||||
return _executor
|
||||
146
backend/app/agents/background/scheduler.py
Normal file
146
backend/app/agents/background/scheduler.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Background task scheduler - Phase 10.4"""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
|
||||
from .manager import BackgroundTaskManager, get_background_task_manager
|
||||
|
||||
|
||||
class BackgroundScheduler:
|
||||
"""Background task scheduler using APScheduler.
|
||||
|
||||
Integrates with BackgroundTaskManager for task tracking and execution.
|
||||
"""
|
||||
|
||||
def __init__(self, task_manager: BackgroundTaskManager | None = None):
|
||||
"""Initialize the scheduler.
|
||||
|
||||
Args:
|
||||
task_manager: Optional BackgroundTaskManager instance.
|
||||
If not provided, uses the global singleton.
|
||||
"""
|
||||
self._scheduler = AsyncIOScheduler()
|
||||
self._task_manager = task_manager or get_background_task_manager()
|
||||
self._job_tasks: dict[str, str] = {} # Maps APScheduler job_id to task_id
|
||||
|
||||
def add_job(
|
||||
self,
|
||||
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||
trigger: BaseTrigger,
|
||||
args: tuple[Any, ...] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
**apscheduler_kwargs: Any,
|
||||
) -> str:
|
||||
"""Add a job to the scheduler.
|
||||
|
||||
Args:
|
||||
func: Async function to execute
|
||||
trigger: APScheduler trigger (date, interval, cron, etc.)
|
||||
args: Positional arguments for the function
|
||||
kwargs: Keyword arguments for the function
|
||||
id: Unique job ID (auto-generated if not provided)
|
||||
name: Job name for display purposes
|
||||
**apscheduler_kwargs: Additional APScheduler options
|
||||
|
||||
Returns:
|
||||
The job ID
|
||||
"""
|
||||
job_id = id or f"job_{len(self._job_tasks)}"
|
||||
task_name = name or f"scheduled_task_{job_id}"
|
||||
|
||||
# Wrap the async function to integrate with BackgroundTaskManager
|
||||
async def wrapped_func() -> None:
|
||||
coro = func(*(args or ()), **(kwargs or {}))
|
||||
task_id = self._task_manager.submit_task(task_name, coro)
|
||||
self._job_tasks[job_id] = task_id
|
||||
|
||||
self._scheduler.add_job(
|
||||
wrapped_func,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
name=task_name,
|
||||
**apscheduler_kwargs,
|
||||
)
|
||||
|
||||
return job_id
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
"""Remove a job from the scheduler.
|
||||
|
||||
Args:
|
||||
job_id: The ID of the job to remove
|
||||
|
||||
Returns:
|
||||
True if job was removed, False if job didn't exist
|
||||
"""
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
# Clean up task mapping if exists
|
||||
if job_id in self._job_tasks:
|
||||
task_id = self._job_tasks.pop(job_id)
|
||||
# Cancel the background task if still running
|
||||
self._task_manager.cancel_task(task_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_jobs(self) -> list[dict[str, Any]]:
|
||||
"""List all scheduled jobs.
|
||||
|
||||
Returns:
|
||||
List of job information dictionaries
|
||||
"""
|
||||
jobs = self._scheduler.get_jobs()
|
||||
return [
|
||||
{
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"next_run_time": job.next_run_time,
|
||||
"trigger": str(job.trigger),
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the scheduler."""
|
||||
if not self._scheduler.running:
|
||||
self._scheduler.start()
|
||||
|
||||
def shutdown(self, wait: bool = True) -> None:
|
||||
"""Shutdown the scheduler.
|
||||
|
||||
Args:
|
||||
wait: Whether to wait for running jobs to complete
|
||||
"""
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown(wait=wait)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the scheduler."""
|
||||
self._scheduler.pause()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the scheduler."""
|
||||
self._scheduler.resume()
|
||||
|
||||
@property
|
||||
def task_manager(self) -> BackgroundTaskManager:
|
||||
"""Get the underlying task manager."""
|
||||
return self._task_manager
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
|
||||
def get_background_scheduler() -> BackgroundScheduler:
|
||||
"""Get the global BackgroundScheduler instance."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = BackgroundScheduler()
|
||||
return _scheduler
|
||||
508
backend/app/agents/coordinator.py
Normal file
508
backend/app/agents/coordinator.py
Normal file
@@ -0,0 +1,508 @@
|
||||
"""Agent 协调整器 - Phase 10.5
|
||||
|
||||
统一协调所有 Agent 组件:TeamLeader, RemoteTransport, BackgroundTaskManager, SessionManager
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.agents.background.manager import BackgroundTaskManager, get_background_task_manager
|
||||
from app.agents.session.manager import AgentSession, create_agent_session, get_agent_session
|
||||
from app.agents.team.leader import TeamLeader
|
||||
from app.agents.transport.remote import RemoteTransport
|
||||
|
||||
|
||||
class AgentCoordinator:
|
||||
"""Agent 协调整器
|
||||
|
||||
统一协调所有 Agent 组件,提供单一入口处理各类 Agent 操作。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
background_manager: BackgroundTaskManager | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
background_manager: 后台任务管理器,None 则使用全局单例
|
||||
"""
|
||||
self._team_leaders: dict[str, TeamLeader] = {}
|
||||
self._remote_transport = RemoteTransport()
|
||||
self._background_manager = background_manager or get_background_task_manager()
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
|
||||
# === Team 协作方法 ===
|
||||
|
||||
def create_team(self, team_id: str, members: list[str]) -> dict[str, Any]:
|
||||
"""创建团队
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
members: 成员 ID 列表
|
||||
|
||||
Returns:
|
||||
团队创建结果
|
||||
"""
|
||||
if team_id in self._team_leaders:
|
||||
return {"status": "error", "message": f"Team '{team_id}' already exists"}
|
||||
|
||||
leader = TeamLeader(team_id=team_id, members=members)
|
||||
self._team_leaders[team_id] = leader
|
||||
return {
|
||||
"status": "created",
|
||||
"team_id": team_id,
|
||||
"members": members,
|
||||
}
|
||||
|
||||
def get_team(self, team_id: str) -> TeamLeader | None:
|
||||
"""获取团队
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
|
||||
Returns:
|
||||
TeamLeader 或 None
|
||||
"""
|
||||
return self._team_leaders.get(team_id)
|
||||
|
||||
def assign_task(self, team_id: str, description: str, member: str) -> dict[str, Any]:
|
||||
"""创建并分配任务
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
description: 任务描述
|
||||
member: 成员 ID
|
||||
|
||||
Returns:
|
||||
分配结果
|
||||
"""
|
||||
leader = self._team_leaders.get(team_id)
|
||||
if not leader:
|
||||
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||
|
||||
task_id = leader.create_task(description)
|
||||
success = leader.assign_task(task_id, member)
|
||||
return {
|
||||
"status": "assigned" if success else "error",
|
||||
"task_id": task_id,
|
||||
"assignee": member,
|
||||
}
|
||||
|
||||
def broadcast_task(self, team_id: str, description: str) -> dict[str, Any]:
|
||||
"""广播任务给所有成员
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
description: 任务描述
|
||||
|
||||
Returns:
|
||||
广播结果
|
||||
"""
|
||||
leader = self._team_leaders.get(team_id)
|
||||
if not leader:
|
||||
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||
|
||||
task_ids = leader.broadcast_task(description)
|
||||
return {
|
||||
"status": "broadcast",
|
||||
"team_id": team_id,
|
||||
"task_ids": task_ids,
|
||||
"member_count": len(leader.members),
|
||||
}
|
||||
|
||||
def collect_team_results(self, team_id: str) -> dict[str, Any]:
|
||||
"""收集团队任务结果
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
|
||||
Returns:
|
||||
收集结果
|
||||
"""
|
||||
leader = self._team_leaders.get(team_id)
|
||||
if not leader:
|
||||
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||
|
||||
results = leader.collect_results()
|
||||
status = leader.get_team_status()
|
||||
return {
|
||||
"status": "collected",
|
||||
"team_id": team_id,
|
||||
"results": results,
|
||||
"completed": status["completed"],
|
||||
"failed": status["failed"],
|
||||
}
|
||||
|
||||
def get_team_status(self, team_id: str) -> dict[str, Any]:
|
||||
"""获取团队状态
|
||||
|
||||
Args:
|
||||
team_id: 团队 ID
|
||||
|
||||
Returns:
|
||||
团队状态
|
||||
"""
|
||||
leader = self._team_leaders.get(team_id)
|
||||
if not leader:
|
||||
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||
|
||||
return leader.get_team_status()
|
||||
|
||||
# === 后台任务方法 ===
|
||||
|
||||
def submit_background_task(
|
||||
self,
|
||||
name: str,
|
||||
coro: Any,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""提交后台任务
|
||||
|
||||
Args:
|
||||
name: 任务名称
|
||||
coro: 协程函数
|
||||
*args: 位置参数
|
||||
**kwargs: 关键字参数
|
||||
|
||||
Returns:
|
||||
提交结果
|
||||
"""
|
||||
task_id = self._background_manager.submit_task(name, coro, *args, **kwargs)
|
||||
return {
|
||||
"status": "submitted",
|
||||
"task_id": task_id,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
def cancel_background_task(self, task_id: str) -> dict[str, Any]:
|
||||
"""取消后台任务
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
取消结果
|
||||
"""
|
||||
success = self._background_manager.cancel_task(task_id)
|
||||
return {
|
||||
"status": "cancelled" if success else "error",
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
def get_background_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""获取后台任务状态
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
任务状态
|
||||
"""
|
||||
task = self._background_manager.get_task_status(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "message": f"Task '{task_id}' not found"}
|
||||
|
||||
return {
|
||||
"status": "found",
|
||||
"task_id": task.id,
|
||||
"name": task.name,
|
||||
"task_status": task.status.value,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
}
|
||||
|
||||
def list_background_tasks(self) -> dict[str, Any]:
|
||||
"""列出所有后台任务
|
||||
|
||||
Returns:
|
||||
任务列表
|
||||
"""
|
||||
tasks = self._background_manager.list_tasks()
|
||||
return {
|
||||
"status": "list",
|
||||
"count": len(tasks),
|
||||
"tasks": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"status": t.status.value,
|
||||
}
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
|
||||
# === 会话方法 ===
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
parent_session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""创建会话
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
parent_session_id: 父会话 ID
|
||||
|
||||
Returns:
|
||||
创建结果
|
||||
"""
|
||||
session = create_agent_session(
|
||||
user_id=user_id,
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
self._sessions[session.session_id] = session
|
||||
return {
|
||||
"status": "created",
|
||||
"session_id": session.session_id,
|
||||
"user_id": user_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
}
|
||||
|
||||
def get_session(self, session_id: str) -> AgentSession | None:
|
||||
"""获取会话
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
|
||||
Returns:
|
||||
AgentSession 或 None
|
||||
"""
|
||||
return self._sessions.get(session_id) or get_agent_session(session_id)
|
||||
|
||||
async def process_session_message(
|
||||
self,
|
||||
session_id: str,
|
||||
message: str,
|
||||
response: str,
|
||||
) -> dict[str, Any]:
|
||||
"""处理会话消息
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
message: 用户消息
|
||||
response: 助手响应
|
||||
|
||||
Returns:
|
||||
处理结果
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||
|
||||
await session.process_message(message, response)
|
||||
return {
|
||||
"status": "processed",
|
||||
"session_id": session_id,
|
||||
"message_count": session.context.message_count,
|
||||
}
|
||||
|
||||
async def spawn_child_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""创建子会话
|
||||
|
||||
Args:
|
||||
session_id: 父会话 ID
|
||||
user_id: 用户 ID
|
||||
|
||||
Returns:
|
||||
创建结果
|
||||
"""
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||
|
||||
child = await session.spawn_child_session(user_id=user_id)
|
||||
self._sessions[child.session_id] = child
|
||||
return {
|
||||
"status": "spawned",
|
||||
"parent_session_id": session_id,
|
||||
"child_session_id": child.session_id,
|
||||
"depth": child.context.depth,
|
||||
}
|
||||
|
||||
def get_session_summary(self, session_id: str) -> dict[str, Any]:
|
||||
"""获取会话摘要
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
|
||||
Returns:
|
||||
会话摘要
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||
|
||||
# get_session_summary is async, so we need to run it
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a future
|
||||
future = asyncio.ensure_future(session.get_session_summary())
|
||||
return {"status": "found", "summary": future}
|
||||
else:
|
||||
return {
|
||||
"status": "found",
|
||||
"summary": loop.run_until_complete(session.get_session_summary()),
|
||||
}
|
||||
except RuntimeError:
|
||||
# No event loop, create one
|
||||
return {"status": "found", "summary": asyncio.run(session.get_session_summary())}
|
||||
|
||||
# === 远程传输方法 ===
|
||||
|
||||
def register_remote_handler(self, event_type: str, handler: Any) -> None:
|
||||
"""注册远程消息处理器
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
handler: 处理函数
|
||||
"""
|
||||
self._remote_transport.register_handler(event_type, handler)
|
||||
|
||||
async def send_remote_response(
|
||||
self,
|
||||
session_id: str,
|
||||
response: dict[str, Any],
|
||||
) -> bool:
|
||||
"""发送远程响应
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
response: 响应数据
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
return await self._remote_transport.send_response(session_id, response)
|
||||
|
||||
async def send_remote_event(
|
||||
self,
|
||||
session_id: str,
|
||||
event: dict[str, Any],
|
||||
) -> bool:
|
||||
"""发送远程事件
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
event: 事件数据
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
return await self._remote_transport.send_event(session_id, event)
|
||||
|
||||
async def send_remote_tool_call(
|
||||
self,
|
||||
session_id: str,
|
||||
tool_call: dict[str, Any],
|
||||
) -> bool:
|
||||
"""发送远程工具调用
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
tool_call: 工具调用数据
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
return await self._remote_transport.send_tool_call(session_id, tool_call)
|
||||
|
||||
# === 统一协调入口 ===
|
||||
|
||||
async def coordinate(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""统一协调入口
|
||||
|
||||
根据请求类型协调各类 Agent 操作。
|
||||
|
||||
Args:
|
||||
request: 请求数据,包含:
|
||||
- action: 操作类型 (team_create, team_assign, task_submit, session_create, etc.)
|
||||
- 其他参数根据 action 不同而不同
|
||||
|
||||
Returns:
|
||||
协调结果
|
||||
"""
|
||||
action = request.get("action")
|
||||
|
||||
if action == "team_create":
|
||||
return self.create_team(
|
||||
team_id=request["team_id"],
|
||||
members=request["members"],
|
||||
)
|
||||
|
||||
elif action == "team_assign":
|
||||
return self.assign_task(
|
||||
team_id=request["team_id"],
|
||||
description=request["description"],
|
||||
member=request["member"],
|
||||
)
|
||||
|
||||
elif action == "team_broadcast":
|
||||
return self.broadcast_task(
|
||||
team_id=request["team_id"],
|
||||
description=request["description"],
|
||||
)
|
||||
|
||||
elif action == "team_collect":
|
||||
return self.collect_team_results(team_id=request["team_id"])
|
||||
|
||||
elif action == "team_status":
|
||||
return self.get_team_status(team_id=request["team_id"])
|
||||
|
||||
elif action == "task_submit":
|
||||
return self.submit_background_task(
|
||||
name=request["name"],
|
||||
coro=request["coro"],
|
||||
*request.get("args", []),
|
||||
**request.get("kwargs", {}),
|
||||
)
|
||||
|
||||
elif action == "task_cancel":
|
||||
return self.cancel_background_task(task_id=request["task_id"])
|
||||
|
||||
elif action == "task_status":
|
||||
return self.get_background_task_status(task_id=request["task_id"])
|
||||
|
||||
elif action == "session_create":
|
||||
return self.create_session(
|
||||
user_id=request.get("user_id"),
|
||||
parent_session_id=request.get("parent_session_id"),
|
||||
)
|
||||
|
||||
elif action == "session_message":
|
||||
return await self.process_session_message(
|
||||
session_id=request["session_id"],
|
||||
message=request["message"],
|
||||
response=request["response"],
|
||||
)
|
||||
|
||||
elif action == "session_spawn":
|
||||
return await self.spawn_child_session(
|
||||
session_id=request["session_id"],
|
||||
user_id=request.get("user_id"),
|
||||
)
|
||||
|
||||
elif action == "session_summary":
|
||||
return self.get_session_summary(session_id=request["session_id"])
|
||||
|
||||
else:
|
||||
return {"status": "error", "message": f"Unknown action: {action}"}
|
||||
|
||||
|
||||
# 全局单例
|
||||
_coordinator: AgentCoordinator | None = None
|
||||
|
||||
|
||||
def get_agent_coordinator() -> AgentCoordinator:
|
||||
"""获取全局 Agent 协调整器"""
|
||||
global _coordinator
|
||||
if _coordinator is None:
|
||||
_coordinator = AgentCoordinator()
|
||||
return _coordinator
|
||||
@@ -12,6 +12,12 @@ from typing import Any, Literal, cast
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from app.agents.isolation import (
|
||||
WorktreeIsolationError,
|
||||
prepare_session_isolation,
|
||||
prepare_worktree_isolation,
|
||||
select_isolation_strategy,
|
||||
)
|
||||
from app.agents.prompts import (
|
||||
ANALYST_SYSTEM_PROMPT,
|
||||
COORDINATOR_SYSTEM_PROMPT,
|
||||
@@ -22,6 +28,12 @@ from app.agents.prompts import (
|
||||
SCHEDULE_PLANNER_SYSTEM_PROMPT,
|
||||
)
|
||||
from app.agents.registry import load_builtin_registry_indexes
|
||||
from app.agents.runtime_metrics import (
|
||||
coerce_cost_thresholds,
|
||||
estimate_token_cost,
|
||||
extract_token_usage,
|
||||
is_cost_budget_warning,
|
||||
)
|
||||
from app.agents.schemas.event import AgentEvent
|
||||
from app.agents.schemas.message import AgentMessage
|
||||
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult
|
||||
@@ -193,6 +205,175 @@ def _get_state_int(state: AgentState, key: str) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _clear_isolation_state(state: AgentState) -> None:
|
||||
state["isolation_mode"] = "none"
|
||||
state["isolation_id"] = None
|
||||
state["isolation_workspace_path"] = None
|
||||
state["isolation_parent_conversation_id"] = None
|
||||
state["isolation_metadata"] = {}
|
||||
|
||||
|
||||
def _apply_isolation_payload(state: AgentState, payload: dict[str, Any]) -> None:
|
||||
state["isolation_mode"] = str(payload.get("mode") or "none")
|
||||
state["isolation_id"] = str(payload.get("isolation_id") or "") or None
|
||||
state["isolation_workspace_path"] = str(payload.get("workspace_path") or "") or None
|
||||
state["isolation_parent_conversation_id"] = str(payload.get("parent_conversation_id") or "") or None
|
||||
state["isolation_metadata"] = dict(payload.get("metadata") or {})
|
||||
|
||||
|
||||
def _prepare_isolation_context(
|
||||
state: AgentState,
|
||||
*,
|
||||
role: AgentRole,
|
||||
sub_commander: str,
|
||||
user_query: str,
|
||||
toolset: list[Any],
|
||||
) -> None:
|
||||
tool_names = [tool.name for tool in toolset]
|
||||
decision = select_isolation_strategy(
|
||||
user_query=user_query,
|
||||
tool_names=tool_names,
|
||||
role_value=role.value,
|
||||
execution_mode=str(state.get("execution_mode") or "direct"),
|
||||
)
|
||||
if decision.mode == "none":
|
||||
_clear_isolation_state(state)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload={"mode": "none", "reason": decision.reason, "tool_names": tool_names},
|
||||
)
|
||||
return
|
||||
|
||||
if decision.mode == "session":
|
||||
isolation_payload = prepare_session_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
_apply_isolation_payload(state, isolation_payload)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload=isolation_payload,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
isolation_payload = prepare_worktree_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
except WorktreeIsolationError as exc:
|
||||
isolation_payload = prepare_session_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
isolation_payload["metadata"] = {
|
||||
**dict(isolation_payload.get("metadata") or {}),
|
||||
"fallback_reason": str(exc),
|
||||
"fallback_from": "worktree",
|
||||
}
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.fallback",
|
||||
payload={
|
||||
"requested_mode": "worktree",
|
||||
"fallback_mode": "session",
|
||||
"reason": str(exc),
|
||||
"tool_names": tool_names,
|
||||
},
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
_apply_isolation_payload(state, isolation_payload)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload=isolation_payload,
|
||||
)
|
||||
|
||||
|
||||
def _record_response_usage(state: AgentState, response: Any) -> None:
|
||||
input_tokens, output_tokens = extract_token_usage(response)
|
||||
if not input_tokens and not output_tokens:
|
||||
return
|
||||
|
||||
current_input_tokens = int(state.get("input_tokens") or 0)
|
||||
current_output_tokens = int(state.get("output_tokens") or 0)
|
||||
total_input_tokens = current_input_tokens + input_tokens
|
||||
total_output_tokens = current_output_tokens + output_tokens
|
||||
state["input_tokens"] = total_input_tokens
|
||||
state["output_tokens"] = total_output_tokens
|
||||
state["estimated_cost"] = estimate_token_cost(total_input_tokens, total_output_tokens)
|
||||
|
||||
thresholds = coerce_cost_thresholds(state.get("cost_thresholds"))
|
||||
state["cost_thresholds"] = thresholds
|
||||
budget_warning = is_cost_budget_warning(
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
state.get("estimated_cost"),
|
||||
thresholds,
|
||||
)
|
||||
previous_budget_warning = bool(state.get("budget_warning") or False)
|
||||
state["budget_warning"] = budget_warning
|
||||
|
||||
agent_id = str(state.get("agent_id") or state.get("current_agent") or AgentRole.MASTER.value)
|
||||
cost_by_agent = {
|
||||
key: dict(value)
|
||||
for key, value in dict(state.get("cost_by_agent") or {}).items()
|
||||
}
|
||||
agent_totals = dict(cost_by_agent.get(agent_id) or {})
|
||||
agent_input_tokens = int(agent_totals.get("input_tokens") or 0) + input_tokens
|
||||
agent_output_tokens = int(agent_totals.get("output_tokens") or 0) + output_tokens
|
||||
agent_estimated_cost = estimate_token_cost(agent_input_tokens, agent_output_tokens)
|
||||
cost_by_agent[agent_id] = {
|
||||
"agent_id": agent_id,
|
||||
"input_tokens": agent_input_tokens,
|
||||
"output_tokens": agent_output_tokens,
|
||||
"total_tokens": agent_input_tokens + agent_output_tokens,
|
||||
"estimated_cost": agent_estimated_cost,
|
||||
"budget_warning": is_cost_budget_warning(
|
||||
agent_input_tokens,
|
||||
agent_output_tokens,
|
||||
agent_estimated_cost,
|
||||
thresholds,
|
||||
),
|
||||
}
|
||||
state["cost_by_agent"] = cost_by_agent
|
||||
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.cost.updated",
|
||||
payload={
|
||||
"agent_id": agent_id,
|
||||
"input_tokens_delta": input_tokens,
|
||||
"output_tokens_delta": output_tokens,
|
||||
"input_tokens": total_input_tokens,
|
||||
"output_tokens": total_output_tokens,
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": budget_warning,
|
||||
},
|
||||
)
|
||||
if budget_warning and not previous_budget_warning:
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.cost.warning",
|
||||
payload={
|
||||
"thresholds": thresholds,
|
||||
"input_tokens": total_input_tokens,
|
||||
"output_tokens": total_output_tokens,
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
},
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def _role_values() -> set[str]:
|
||||
return {role.value for role in AgentRole}
|
||||
|
||||
@@ -1120,6 +1301,43 @@ def _append_event_trace(
|
||||
]
|
||||
|
||||
|
||||
def _set_phase(state: AgentState, phase: str, *, reason: str, payload: dict[str, Any] | None = None) -> None:
|
||||
if state.get("current_phase") == phase:
|
||||
return
|
||||
state["current_phase"] = phase
|
||||
state["phase_history"] = [
|
||||
*(state.get("phase_history") or []),
|
||||
{
|
||||
"phase": phase,
|
||||
"reason": reason,
|
||||
**({"payload": payload} if payload else {}),
|
||||
},
|
||||
]
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.phase.changed",
|
||||
payload={"phase": phase, "reason": reason, **(payload or {})},
|
||||
)
|
||||
|
||||
|
||||
def _record_checkpoint(state: AgentState, checkpoint: str, *, reason: str, payload: dict[str, Any] | None = None) -> None:
|
||||
state["current_checkpoint"] = checkpoint
|
||||
state["checkpoint_history"] = [
|
||||
*(state.get("checkpoint_history") or []),
|
||||
{
|
||||
"checkpoint": checkpoint,
|
||||
"phase": state.get("current_phase"),
|
||||
"reason": reason,
|
||||
**({"payload": payload} if payload else {}),
|
||||
},
|
||||
]
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.checkpoint.recorded",
|
||||
payload={"checkpoint": checkpoint, "phase": state.get("current_phase"), "reason": reason, **(payload or {})},
|
||||
)
|
||||
|
||||
|
||||
def _capability_manifest_for_tool(tool_name: str):
|
||||
indexes = load_builtin_registry_indexes()
|
||||
capability_id = indexes.capability_id_by_tool_name.get(tool_name)
|
||||
@@ -1488,6 +1706,10 @@ async def _execute_tool_calls(
|
||||
"args": normalized_args,
|
||||
"result_preview": _stringify_message_content(result)[:200],
|
||||
"verifier_hints": verifier_hints,
|
||||
"isolation": {
|
||||
"mode": state.get("isolation_mode"),
|
||||
"workspace_path": state.get("isolation_workspace_path"),
|
||||
},
|
||||
}
|
||||
state["tool_outcomes"] = [*(state.get("tool_outcomes") or []), tool_outcome]
|
||||
_append_event_trace(
|
||||
@@ -1549,6 +1771,13 @@ async def _run_sub_commander(
|
||||
_record_sub_commander(state, role, sub_commander, user_query)
|
||||
|
||||
toolset = SUB_COMMANDER_TOOLSETS.get(sub_commander, []) if use_tools else []
|
||||
_prepare_isolation_context(
|
||||
state,
|
||||
role=role,
|
||||
sub_commander=sub_commander,
|
||||
user_query=user_query,
|
||||
toolset=toolset,
|
||||
)
|
||||
if (
|
||||
role == AgentRole.EXECUTOR
|
||||
and _is_short_confirmation(user_query)
|
||||
@@ -1583,6 +1812,7 @@ async def _run_sub_commander(
|
||||
if _guard_sub_commander_budget(state, "iteration_count", "max_iterations", "max_iterations_exceeded"):
|
||||
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
|
||||
response = await _invoke_llm(llm, working_messages)
|
||||
_record_response_usage(state, response)
|
||||
state["final_response"] = _stringify_message_content(response.content)
|
||||
elif capabilities.supports_native_tools:
|
||||
state["tool_strategy_used"] = "native"
|
||||
@@ -1592,6 +1822,7 @@ async def _run_sub_commander(
|
||||
break
|
||||
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
|
||||
response = await _invoke_llm(bound_llm, working_messages)
|
||||
_record_response_usage(state, response)
|
||||
tool_calls = getattr(response, "tool_calls", None) or []
|
||||
if tool_calls:
|
||||
if not _guard_sub_commander_budget(state, "tool_round_count", "max_tool_rounds", "max_tool_rounds_exceeded"):
|
||||
@@ -1653,6 +1884,7 @@ async def _run_sub_commander(
|
||||
*([retry_instruction] if retry_instruction else []),
|
||||
],
|
||||
)
|
||||
_record_response_usage(state, response)
|
||||
response_text = _stringify_message_content(response.content)
|
||||
parsed = _parse_json_action(response_text, allowed_tools)
|
||||
if parsed is None and response_text.strip() and state.get("tool_round_count"):
|
||||
@@ -1804,6 +2036,27 @@ def _build_task_evidence(state: AgentState, start_index: int) -> list[dict[str,
|
||||
else:
|
||||
evidence = []
|
||||
|
||||
if state.get("isolation_mode") and state.get("isolation_mode") != "none":
|
||||
evidence.append(
|
||||
{
|
||||
"type": "isolation",
|
||||
"mode": state.get("isolation_mode"),
|
||||
"workspace_path": state.get("isolation_workspace_path"),
|
||||
"metadata": dict(state.get("isolation_metadata") or {}),
|
||||
}
|
||||
)
|
||||
|
||||
if state.get("input_tokens") or state.get("output_tokens"):
|
||||
evidence.append(
|
||||
{
|
||||
"type": "cost",
|
||||
"input_tokens": int(state.get("input_tokens") or 0),
|
||||
"output_tokens": int(state.get("output_tokens") or 0),
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": bool(state.get("budget_warning") or False),
|
||||
}
|
||||
)
|
||||
|
||||
if state.get("verification_status") or state.get("verification_summary"):
|
||||
evidence.append(
|
||||
{
|
||||
@@ -1846,6 +2099,10 @@ def _collect_task_result(task: AgentTask, state: AgentState, start_tool_index: i
|
||||
"role": task.role,
|
||||
"sub_commander": state.get("current_sub_commander"),
|
||||
"verification_status": state.get("verification_status"),
|
||||
"isolation_mode": state.get("isolation_mode"),
|
||||
"isolation_workspace_path": state.get("isolation_workspace_path"),
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": bool(state.get("budget_warning") or False),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1959,10 +2216,15 @@ def _verify_collaboration_results(
|
||||
|
||||
|
||||
async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState:
|
||||
_set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started")
|
||||
_record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started")
|
||||
tasks = _build_collaboration_tasks(user_query)
|
||||
if len(tasks) < 2:
|
||||
state["execution_mode"] = "direct"
|
||||
state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"}
|
||||
_record_checkpoint(state, "collaboration.fallback_to_direct", reason="insufficient_tasks", payload={"task_count": len(tasks)})
|
||||
_set_phase(state, "phase_1_routing", reason="collaboration_flow_abandoned", payload={"task_count": len(tasks)})
|
||||
_record_checkpoint(state, "routing.direct_resumed", reason="collaboration_flow_abandoned", payload={"task_count": len(tasks)})
|
||||
return state
|
||||
|
||||
base_history = list(state.get("messages", []))
|
||||
@@ -1988,12 +2250,15 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
payload=budget_snapshot,
|
||||
)
|
||||
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
|
||||
_record_checkpoint(state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)})
|
||||
parent_task_id = next((task.parent_task_id for task in tasks if task.parent_task_id), None) or "root"
|
||||
state["task_hierarchy"] = {parent_task_id: [task.task_id for task in tasks]}
|
||||
state["task_results"] = []
|
||||
state["next_step"] = None
|
||||
|
||||
_set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch")
|
||||
for task in tasks:
|
||||
_record_checkpoint(state, "collaboration.task_dispatch", reason="dispatch_task", payload={"task_id": task.task_id, "role": task.role})
|
||||
state["current_agent"] = AgentRole.MASTER.value
|
||||
state["agent_id"] = coordinator_agent_id
|
||||
state["parent_agent_id"] = None
|
||||
@@ -2046,6 +2311,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
)
|
||||
|
||||
task_result = _collect_task_result(task, state, start_tool_index)
|
||||
_record_checkpoint(state, "collaboration.task_result_collected", reason="task_finished", payload={"task_id": task.task_id, "status": task_result.status})
|
||||
_append_message_trace(
|
||||
state,
|
||||
from_agent_id=child_agent_id,
|
||||
@@ -2077,6 +2343,8 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
state["root_agent_id"] = root_agent_id
|
||||
state["collaboration_depth"] = 0
|
||||
state["final_response"] = _build_collaboration_final_response(state.get("task_results") or [])
|
||||
_set_phase(state, "phase_4_visibility_and_verification", reason="collaboration_verification_started")
|
||||
_record_checkpoint(state, "collaboration.verification_started", reason="before_verify")
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.verify.started",
|
||||
@@ -2096,6 +2364,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
},
|
||||
severity="error" if state.get("verification_status") == "failed" else "info",
|
||||
)
|
||||
_record_checkpoint(state, "collaboration.completed", reason="collaboration_flow_finished", payload={"verification_status": state.get("verification_status")})
|
||||
state["messages"] = [*base_history, AIMessage(content=state["final_response"])]
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
@@ -2114,6 +2383,8 @@ def _stop_due_to_loop_guard(state: AgentState) -> AgentState:
|
||||
|
||||
async def master_node(state: AgentState) -> AgentState:
|
||||
_maybe_reset_turn_budgets(state)
|
||||
_set_phase(state, "phase_1_routing", reason="master_node_entered")
|
||||
_record_checkpoint(state, "routing.master_entered", reason="master_node_entered")
|
||||
user_messages = _filter_user_messages(state["messages"])
|
||||
user_query = _stringify_message_content(user_messages[-1].content).strip() if user_messages else ""
|
||||
|
||||
@@ -2179,6 +2450,7 @@ async def master_node(state: AgentState) -> AgentState:
|
||||
|
||||
llm = _get_llm_for_state(state)
|
||||
response = await _invoke_llm(llm, [SystemMessage(content=MASTER_SYSTEM_PROMPT), *state["messages"]])
|
||||
_record_response_usage(state, response)
|
||||
content = _stringify_message_content(response.content).strip()
|
||||
|
||||
routed_agent = _route_agent_from_user_query(content)
|
||||
|
||||
14
backend/app/agents/isolation/__init__.py
Normal file
14
backend/app/agents/isolation/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.agents.isolation.session_isolation import prepare_session_isolation
|
||||
from app.agents.isolation.strategy_selector import IsolationDecision, select_isolation_strategy
|
||||
from app.agents.isolation.worktree_isolation import (
|
||||
WorktreeIsolationError,
|
||||
prepare_worktree_isolation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"IsolationDecision",
|
||||
"WorktreeIsolationError",
|
||||
"prepare_session_isolation",
|
||||
"prepare_worktree_isolation",
|
||||
"select_isolation_strategy",
|
||||
]
|
||||
31
backend/app/agents/isolation/session_isolation.py
Normal file
31
backend/app/agents/isolation/session_isolation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.agents.isolation.strategy_selector import IsolationDecision
|
||||
|
||||
|
||||
def prepare_session_isolation(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
decision: IsolationDecision,
|
||||
role_value: str,
|
||||
sub_commander: str,
|
||||
) -> dict[str, Any]:
|
||||
isolation_id = f"session-{uuid4().hex[:8]}"
|
||||
return {
|
||||
"mode": "session",
|
||||
"isolation_id": isolation_id,
|
||||
"workspace_path": None,
|
||||
"parent_conversation_id": str(state.get("conversation_id") or "") or None,
|
||||
"metadata": {
|
||||
**dict(decision.metadata or {}),
|
||||
"reason": decision.reason,
|
||||
"role": role_value,
|
||||
"sub_commander": sub_commander,
|
||||
"tool_names": list(decision.tool_names),
|
||||
"capability_ids": list(decision.capability_ids),
|
||||
"status": "active",
|
||||
},
|
||||
}
|
||||
147
backend/app/agents/isolation/strategy_selector.py
Normal file
147
backend/app/agents/isolation/strategy_selector.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.agents.registry import load_builtin_registry_indexes
|
||||
from app.agents.registry.models import CapabilityManifest, PermissionClass, SideEffectScope
|
||||
|
||||
|
||||
IsolationMode = Literal["none", "session", "worktree"]
|
||||
|
||||
_WORKTREE_QUERY_MARKERS = (
|
||||
"code",
|
||||
"repo",
|
||||
"repository",
|
||||
"git",
|
||||
"worktree",
|
||||
"branch",
|
||||
"patch",
|
||||
"diff",
|
||||
"refactor",
|
||||
"build",
|
||||
"test",
|
||||
"fix",
|
||||
"file",
|
||||
"files",
|
||||
"python",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"代码",
|
||||
"仓库",
|
||||
"分支",
|
||||
"补丁",
|
||||
"重构",
|
||||
"构建",
|
||||
"测试",
|
||||
"修复",
|
||||
"文件",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IsolationDecision:
|
||||
mode: IsolationMode
|
||||
reason: str
|
||||
tool_names: tuple[str, ...] = ()
|
||||
capability_ids: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _capability_metadata(capability: CapabilityManifest | None) -> dict[str, Any]:
|
||||
if capability is None:
|
||||
return {}
|
||||
return {
|
||||
"capability_id": capability.capability_id,
|
||||
"tool_name": capability.tool_name,
|
||||
"permission_class": capability.permission_class.value,
|
||||
"side_effect_scope": capability.side_effect_scope.value,
|
||||
"supports_retry": capability.supports_retry,
|
||||
"idempotent": capability.idempotent,
|
||||
"safe_for_parallel_use": capability.safe_for_parallel_use,
|
||||
"requires_confirmation": capability.requires_confirmation,
|
||||
}
|
||||
|
||||
|
||||
def select_isolation_strategy(
|
||||
*,
|
||||
user_query: str,
|
||||
tool_names: list[str] | tuple[str, ...],
|
||||
role_value: str,
|
||||
execution_mode: str | None,
|
||||
) -> IsolationDecision:
|
||||
indexes = load_builtin_registry_indexes()
|
||||
capabilities: list[CapabilityManifest] = []
|
||||
capability_ids: list[str] = []
|
||||
|
||||
for tool_name in tool_names:
|
||||
capability_id = indexes.capability_id_by_tool_name.get(tool_name)
|
||||
capability = indexes.capability_by_id.get(capability_id) if capability_id else None
|
||||
if capability is not None:
|
||||
capabilities.append(capability)
|
||||
capability_ids.append(capability.capability_id)
|
||||
|
||||
normalized_query = (user_query or "").strip().lower()
|
||||
has_worktree_query_signal = any(marker in normalized_query for marker in _WORKTREE_QUERY_MARKERS)
|
||||
has_write_capability = any(cap.permission_class == PermissionClass.WRITE for cap in capabilities)
|
||||
has_external_capability = any(cap.permission_class == PermissionClass.EXTERNAL for cap in capabilities)
|
||||
has_non_parallel_capability = any(not cap.safe_for_parallel_use for cap in capabilities)
|
||||
has_stateful_side_effect = any(
|
||||
cap.side_effect_scope in {SideEffectScope.LOCAL_STATE, SideEffectScope.DB_WRITE}
|
||||
for cap in capabilities
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"role": role_value,
|
||||
"execution_mode": execution_mode,
|
||||
"capabilities": [_capability_metadata(capability) for capability in capabilities],
|
||||
"workspace_strategy": "inline",
|
||||
"risk_level": "low",
|
||||
}
|
||||
|
||||
if has_worktree_query_signal:
|
||||
return IsolationDecision(
|
||||
mode="worktree",
|
||||
reason="workspace_mutation_signals_detected",
|
||||
tool_names=tuple(tool_names),
|
||||
capability_ids=tuple(capability_ids),
|
||||
metadata={
|
||||
**metadata,
|
||||
"workspace_strategy": "ephemeral_worktree",
|
||||
"risk_level": "high",
|
||||
},
|
||||
)
|
||||
|
||||
if has_write_capability or has_stateful_side_effect or has_non_parallel_capability:
|
||||
return IsolationDecision(
|
||||
mode="session",
|
||||
reason="stateful_or_non_parallel_tooling",
|
||||
tool_names=tuple(tool_names),
|
||||
capability_ids=tuple(capability_ids),
|
||||
metadata={
|
||||
**metadata,
|
||||
"workspace_strategy": "isolated_session",
|
||||
"risk_level": "medium",
|
||||
},
|
||||
)
|
||||
|
||||
if execution_mode == "collaboration" or role_value in {"analyst", "librarian"} or has_external_capability:
|
||||
return IsolationDecision(
|
||||
mode="session",
|
||||
reason="context_heavy_or_external_retrieval",
|
||||
tool_names=tuple(tool_names),
|
||||
capability_ids=tuple(capability_ids),
|
||||
metadata={
|
||||
**metadata,
|
||||
"workspace_strategy": "isolated_session",
|
||||
"risk_level": "medium",
|
||||
},
|
||||
)
|
||||
|
||||
return IsolationDecision(
|
||||
mode="none",
|
||||
reason="inline_execution_is_sufficient",
|
||||
tool_names=tuple(tool_names),
|
||||
capability_ids=tuple(capability_ids),
|
||||
metadata=metadata,
|
||||
)
|
||||
83
backend/app/agents/isolation/worktree_isolation.py
Normal file
83
backend/app/agents/isolation/worktree_isolation.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.agents.isolation.strategy_selector import IsolationDecision
|
||||
|
||||
|
||||
class WorktreeIsolationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _slugify(value: str, *, fallback: str) -> str:
|
||||
slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", (value or "").strip()).strip("-").lower()
|
||||
return slug or fallback
|
||||
|
||||
|
||||
def _resolve_git_root() -> Path:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise WorktreeIsolationError(exc.stderr.strip() or exc.stdout.strip() or "git_root_unavailable") from exc
|
||||
git_root = Path(result.stdout.strip())
|
||||
if not git_root.exists():
|
||||
raise WorktreeIsolationError("git_root_not_found")
|
||||
return git_root
|
||||
|
||||
|
||||
def prepare_worktree_isolation(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
decision: IsolationDecision,
|
||||
role_value: str,
|
||||
sub_commander: str,
|
||||
create_workspace: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
isolation_id = f"worktree-{uuid4().hex[:8]}"
|
||||
conversation_slug = _slugify(str(state.get("conversation_id") or "conversation"), fallback="conversation")
|
||||
role_slug = _slugify(role_value, fallback="agent")
|
||||
git_root = _resolve_git_root()
|
||||
workspace_root = git_root / ".worktrees" / "jarvis" / conversation_slug
|
||||
workspace_path = workspace_root / f"{role_slug}-{isolation_id}"
|
||||
branch = f"jarvis/{conversation_slug}/{role_slug}-{isolation_id}"
|
||||
|
||||
if create_workspace and not workspace_path.exists():
|
||||
workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(git_root), "worktree", "add", "-b", branch, str(workspace_path), "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise WorktreeIsolationError(exc.stderr.strip() or exc.stdout.strip() or "worktree_add_failed") from exc
|
||||
|
||||
return {
|
||||
"mode": "worktree",
|
||||
"isolation_id": isolation_id,
|
||||
"workspace_path": str(workspace_path),
|
||||
"parent_conversation_id": str(state.get("conversation_id") or "") or None,
|
||||
"metadata": {
|
||||
**dict(decision.metadata or {}),
|
||||
"reason": decision.reason,
|
||||
"role": role_value,
|
||||
"sub_commander": sub_commander,
|
||||
"tool_names": list(decision.tool_names),
|
||||
"capability_ids": list(decision.capability_ids),
|
||||
"repo_root": str(git_root),
|
||||
"branch": branch,
|
||||
"workspace_strategy": "ephemeral_worktree",
|
||||
"cleanup_status": "pending",
|
||||
"materialized": workspace_path.exists(),
|
||||
},
|
||||
}
|
||||
19
backend/app/agents/plugins/builtins/code_helper/__init__.py
Normal file
19
backend/app/agents/plugins/builtins/code_helper/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Code Helper Plugin - Linting, formatting, and code explanation tools"""
|
||||
|
||||
|
||||
def lint_file(file_path: str) -> dict:
|
||||
"""Lint a source file and return issues found."""
|
||||
return {"status": "ok", "tool": "lint_file", "result": f"Linting {file_path}"}
|
||||
|
||||
|
||||
def format_file(file_path: str) -> dict:
|
||||
"""Format a source file and return the result."""
|
||||
return {"status": "ok", "tool": "format_file", "result": f"Formatting {file_path}"}
|
||||
|
||||
|
||||
def explain_code(code_snippet: str) -> dict:
|
||||
"""Explain a code snippet and return the explanation."""
|
||||
return {"status": "ok", "tool": "explain_code", "result": f"Explaining code snippet"}
|
||||
|
||||
|
||||
tools = [lint_file, format_file, explain_code]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "code_helper",
|
||||
"name": "Code Helper",
|
||||
"version": "1.0.0",
|
||||
"description": "Code linting, formatting, and explanation tools",
|
||||
"author": "",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"plugin_type": "tool",
|
||||
"main": "__init__.py",
|
||||
"hooks": [],
|
||||
"tools": ["lint_file", "format_file", "explain_code"],
|
||||
"skills": [],
|
||||
"dependencies": {},
|
||||
"peer_dependencies": {},
|
||||
"permissions": [],
|
||||
"allowed_paths": [],
|
||||
"denied_paths": [],
|
||||
"network_allowed": false,
|
||||
"allowed_hosts": [],
|
||||
"config_schema": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""File Organizer Plugin - File organization and duplicate detection tools"""
|
||||
|
||||
|
||||
def organize_by_type(directory: str) -> dict:
|
||||
"""Organize files in a directory by file type."""
|
||||
return {"status": "ok", "tool": "organize_by_type", "result": f"Organizing {directory} by type"}
|
||||
|
||||
|
||||
def find_duplicates(directory: str) -> dict:
|
||||
"""Find duplicate files in a directory."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"tool": "find_duplicates",
|
||||
"result": f"Finding duplicates in {directory}",
|
||||
}
|
||||
|
||||
|
||||
tools = [organize_by_type, find_duplicates]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "file_organizer",
|
||||
"name": "File Organizer",
|
||||
"version": "1.0.0",
|
||||
"description": "File organization and duplicate detection tools",
|
||||
"author": "",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"plugin_type": "tool",
|
||||
"main": "__init__.py",
|
||||
"hooks": [],
|
||||
"tools": ["organize_by_type", "find_duplicates"],
|
||||
"skills": [],
|
||||
"dependencies": {},
|
||||
"peer_dependencies": {},
|
||||
"permissions": [],
|
||||
"allowed_paths": [],
|
||||
"denied_paths": [],
|
||||
"network_allowed": false,
|
||||
"allowed_hosts": [],
|
||||
"config_schema": {}
|
||||
}
|
||||
23
backend/app/agents/plugins/builtins/git_helper/__init__.py
Normal file
23
backend/app/agents/plugins/builtins/git_helper/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Git Helper Plugin - Git status, log, and diff summary tools"""
|
||||
|
||||
|
||||
def git_status_summary() -> dict:
|
||||
"""Get a summary of git status."""
|
||||
return {"status": "ok", "tool": "git_status_summary", "result": "Git status summary"}
|
||||
|
||||
|
||||
def git_log_summary(limit: int = 10) -> dict:
|
||||
"""Get a summary of recent git commits."""
|
||||
return {"status": "ok", "tool": "git_log_summary", "result": f"Git log summary (limit={limit})"}
|
||||
|
||||
|
||||
def git_diff_summary(ref1: str = "HEAD", ref2: str = "HEAD~1") -> dict:
|
||||
"""Get a summary of changes between two refs."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"tool": "git_diff_summary",
|
||||
"result": f"Git diff summary ({ref1}..{ref2})",
|
||||
}
|
||||
|
||||
|
||||
tools = [git_status_summary, git_log_summary, git_diff_summary]
|
||||
22
backend/app/agents/plugins/builtins/git_helper/manifest.json
Normal file
22
backend/app/agents/plugins/builtins/git_helper/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "git_helper",
|
||||
"name": "Git Helper",
|
||||
"version": "1.0.0",
|
||||
"description": "Git status, log, and diff summary tools",
|
||||
"author": "",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"plugin_type": "tool",
|
||||
"main": "__init__.py",
|
||||
"hooks": [],
|
||||
"tools": ["git_status_summary", "git_log_summary", "git_diff_summary"],
|
||||
"skills": [],
|
||||
"dependencies": {},
|
||||
"peer_dependencies": {},
|
||||
"permissions": [],
|
||||
"allowed_paths": [],
|
||||
"denied_paths": [],
|
||||
"network_allowed": false,
|
||||
"allowed_hosts": [],
|
||||
"config_schema": {}
|
||||
}
|
||||
14
backend/app/agents/plugins/builtins/web_helper/__init__.py
Normal file
14
backend/app/agents/plugins/builtins/web_helper/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Web Helper Plugin - Web fetching and HTML parsing tools"""
|
||||
|
||||
|
||||
def fetch_url_content(url: str) -> dict:
|
||||
"""Fetch content from a URL."""
|
||||
return {"status": "ok", "tool": "fetch_url_content", "result": f"Fetching {url}"}
|
||||
|
||||
|
||||
def parse_html_links(html_content: str) -> dict:
|
||||
"""Parse HTML content and extract links."""
|
||||
return {"status": "ok", "tool": "parse_html_links", "result": "Extracted links from HTML"}
|
||||
|
||||
|
||||
tools = [fetch_url_content, parse_html_links]
|
||||
22
backend/app/agents/plugins/builtins/web_helper/manifest.json
Normal file
22
backend/app/agents/plugins/builtins/web_helper/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "web_helper",
|
||||
"name": "Web Helper",
|
||||
"version": "1.0.0",
|
||||
"description": "Web fetching and HTML parsing tools",
|
||||
"author": "",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"plugin_type": "tool",
|
||||
"main": "__init__.py",
|
||||
"hooks": [],
|
||||
"tools": ["fetch_url_content", "parse_html_links"],
|
||||
"skills": [],
|
||||
"dependencies": {},
|
||||
"peer_dependencies": {},
|
||||
"permissions": [],
|
||||
"allowed_paths": [],
|
||||
"denied_paths": [],
|
||||
"network_allowed": true,
|
||||
"allowed_hosts": [],
|
||||
"config_schema": {}
|
||||
}
|
||||
86
backend/app/agents/runtime_metrics.py
Normal file
86
backend/app/agents/runtime_metrics.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
INPUT_TOKEN_USD_RATE = 0.000003
|
||||
OUTPUT_TOKEN_USD_RATE = 0.000015
|
||||
DEFAULT_COST_THRESHOLDS = {
|
||||
"total_tokens": 4000,
|
||||
"estimated_cost": 0.02,
|
||||
}
|
||||
|
||||
|
||||
def estimate_token_cost(input_tokens: int, output_tokens: int) -> float | None:
|
||||
total_tokens = max(input_tokens, 0) + max(output_tokens, 0)
|
||||
if total_tokens <= 0:
|
||||
return None
|
||||
return round(
|
||||
(max(input_tokens, 0) * INPUT_TOKEN_USD_RATE)
|
||||
+ (max(output_tokens, 0) * OUTPUT_TOKEN_USD_RATE),
|
||||
6,
|
||||
)
|
||||
|
||||
|
||||
def extract_token_usage(response: Any) -> tuple[int, int]:
|
||||
usage_metadata = getattr(response, "usage_metadata", None) or {}
|
||||
if isinstance(usage_metadata, dict):
|
||||
input_tokens = int(
|
||||
usage_metadata.get("input_tokens")
|
||||
or usage_metadata.get("prompt_tokens")
|
||||
or 0
|
||||
)
|
||||
output_tokens = int(
|
||||
usage_metadata.get("output_tokens")
|
||||
or usage_metadata.get("completion_tokens")
|
||||
or 0
|
||||
)
|
||||
if input_tokens or output_tokens:
|
||||
return input_tokens, output_tokens
|
||||
|
||||
response_metadata = getattr(response, "response_metadata", None) or {}
|
||||
token_usage = {}
|
||||
if isinstance(response_metadata, dict):
|
||||
token_usage = response_metadata.get("token_usage") or response_metadata.get("usage") or {}
|
||||
if isinstance(token_usage, dict):
|
||||
input_tokens = int(
|
||||
token_usage.get("prompt_tokens")
|
||||
or token_usage.get("input_tokens")
|
||||
or 0
|
||||
)
|
||||
output_tokens = int(
|
||||
token_usage.get("completion_tokens")
|
||||
or token_usage.get("output_tokens")
|
||||
or 0
|
||||
)
|
||||
if input_tokens or output_tokens:
|
||||
return input_tokens, output_tokens
|
||||
|
||||
return 0, 0
|
||||
|
||||
|
||||
def coerce_cost_thresholds(raw_thresholds: Any) -> dict[str, float]:
|
||||
thresholds: dict[str, float] = dict(DEFAULT_COST_THRESHOLDS)
|
||||
if not isinstance(raw_thresholds, dict):
|
||||
return thresholds
|
||||
for key in DEFAULT_COST_THRESHOLDS:
|
||||
value = raw_thresholds.get(key)
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
thresholds[key] = float(value)
|
||||
return thresholds
|
||||
|
||||
|
||||
def is_cost_budget_warning(
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
estimated_cost: float | None,
|
||||
thresholds: dict[str, float] | None = None,
|
||||
) -> bool:
|
||||
effective_thresholds = thresholds or DEFAULT_COST_THRESHOLDS
|
||||
total_tokens = max(input_tokens, 0) + max(output_tokens, 0)
|
||||
token_threshold = float(effective_thresholds.get("total_tokens") or 0)
|
||||
cost_threshold = float(effective_thresholds.get("estimated_cost") or 0)
|
||||
return (
|
||||
(token_threshold > 0 and total_tokens >= token_threshold)
|
||||
or (cost_threshold > 0 and estimated_cost is not None and estimated_cost >= cost_threshold)
|
||||
)
|
||||
@@ -23,6 +23,12 @@ AgentEventType = Literal[
|
||||
"agent.task.recovered",
|
||||
"agent.task.reassigned",
|
||||
"agent.collaboration.budget.updated",
|
||||
"agent.isolation.selected",
|
||||
"agent.isolation.fallback",
|
||||
"agent.cost.updated",
|
||||
"agent.cost.warning",
|
||||
"agent.phase.changed",
|
||||
"agent.checkpoint.recorded",
|
||||
"agent.error",
|
||||
]
|
||||
AgentEventSeverity = Literal["info", "warning", "error"]
|
||||
|
||||
72
backend/app/agents/skills/bundled.py
Normal file
72
backend/app/agents/skills/bundled.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Built-in Skills - Phase 9.4
|
||||
|
||||
This module contains bundled skills that are always available
|
||||
without requiring external skill loaders.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# SkillMetadata-compatible structure for bundled skills
|
||||
BUNDLED_SKILLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "code-analysis",
|
||||
"name": "Code Analysis",
|
||||
"description": "Analyze code structure, patterns, and quality. Helps understand codebase architecture, find issues, and suggest improvements.",
|
||||
"version": "1.0.0",
|
||||
"prompts": [
|
||||
"Analyze the code structure and identify key components, their relationships, and responsibilities.",
|
||||
"Review the code for potential issues like bugs, security vulnerabilities, or performance problems.",
|
||||
"Explain how the code works and what it does in simple terms.",
|
||||
],
|
||||
"tools": ["grep", "read", "glob", "lsp_symbols", "lsp_find_references"],
|
||||
},
|
||||
{
|
||||
"id": "git-helper",
|
||||
"name": "Git Helper",
|
||||
"description": "Assists with Git operations including commit, branch management, merge conflicts, and repository exploration.",
|
||||
"version": "1.0.0",
|
||||
"prompts": [
|
||||
"Show me the current git status and any uncommitted changes.",
|
||||
"Help me create a meaningful commit message for these changes.",
|
||||
"Explain the git history and branch structure of this repository.",
|
||||
],
|
||||
"tools": ["bash"],
|
||||
},
|
||||
{
|
||||
"id": "web-research",
|
||||
"name": "Web Research",
|
||||
"description": "Search the web for information, documentation, and resources. Helps find answers and learn about technologies.",
|
||||
"version": "1.0.0",
|
||||
"prompts": [
|
||||
"Search the web for information about {topic} and summarize the key findings.",
|
||||
"Find official documentation or reliable resources about {topic}.",
|
||||
"Look up the latest news or developments in {topic}.",
|
||||
],
|
||||
"tools": ["search_brave_web_search", "websearch_web_search_exa", "webfetch"],
|
||||
},
|
||||
{
|
||||
"id": "file-management",
|
||||
"name": "File Management",
|
||||
"description": "Helps with file operations like creating, editing, organizing, and managing project files and directories.",
|
||||
"version": "1.0.0",
|
||||
"prompts": [
|
||||
"Create a new file at {path} with the following content: {content}",
|
||||
"Organize the files in the project structure and suggest improvements.",
|
||||
"Find all files related to {topic} or matching {pattern}.",
|
||||
],
|
||||
"tools": ["read", "write", "glob", "bash"],
|
||||
},
|
||||
{
|
||||
"id": "task-planning",
|
||||
"name": "Task Planning",
|
||||
"description": "Helps break down complex tasks into smaller steps, create implementation plans, and track progress.",
|
||||
"version": "1.0.0",
|
||||
"prompts": [
|
||||
"Break down this task into smaller, manageable steps: {task}",
|
||||
"Create an implementation plan for building {feature} with clear phases.",
|
||||
"Review the current progress and suggest next steps for completing {goal}.",
|
||||
],
|
||||
"tools": ["todowrite", "read", "write"],
|
||||
},
|
||||
]
|
||||
@@ -8,6 +8,14 @@ from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRec
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
AgentPhase = Literal[
|
||||
"phase_0_bootstrap",
|
||||
"phase_1_routing",
|
||||
"phase_2_controlled_collaboration",
|
||||
"phase_3_dynamic_collaboration",
|
||||
"phase_4_visibility_and_verification",
|
||||
]
|
||||
|
||||
|
||||
class AgentRole(str, Enum):
|
||||
MASTER = "master"
|
||||
@@ -75,8 +83,23 @@ class AgentState(TypedDict):
|
||||
verification_status: VerificationStatus | None
|
||||
verification_summary: str | None
|
||||
verification_evidence: list[dict[str, Any]]
|
||||
isolation_mode: str
|
||||
isolation_id: str | None
|
||||
isolation_workspace_path: str | None
|
||||
isolation_parent_conversation_id: str | None
|
||||
isolation_metadata: dict[str, Any]
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
estimated_cost: float | None
|
||||
budget_warning: bool
|
||||
cost_by_agent: dict[str, dict[str, Any]]
|
||||
cost_thresholds: dict[str, Any]
|
||||
budget_state: CollaborationBudget | dict[str, Any] | None
|
||||
collaboration_budget_history: list[CollaborationBudget | dict[str, Any]]
|
||||
current_phase: AgentPhase
|
||||
phase_history: list[dict[str, Any]]
|
||||
current_checkpoint: str | None
|
||||
checkpoint_history: list[dict[str, Any]]
|
||||
|
||||
tool_strategy_used: str | None
|
||||
tool_round_count: int
|
||||
@@ -161,8 +184,34 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
verification_status=None,
|
||||
verification_summary=None,
|
||||
verification_evidence=[],
|
||||
isolation_mode="none",
|
||||
isolation_id=None,
|
||||
isolation_workspace_path=None,
|
||||
isolation_parent_conversation_id=None,
|
||||
isolation_metadata={},
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=None,
|
||||
budget_warning=False,
|
||||
cost_by_agent={},
|
||||
cost_thresholds={},
|
||||
budget_state=None,
|
||||
collaboration_budget_history=[],
|
||||
current_phase="phase_0_bootstrap",
|
||||
phase_history=[
|
||||
{
|
||||
"phase": "phase_0_bootstrap",
|
||||
"reason": "initial_state_created",
|
||||
}
|
||||
],
|
||||
current_checkpoint="bootstrap.initialized",
|
||||
checkpoint_history=[
|
||||
{
|
||||
"checkpoint": "bootstrap.initialized",
|
||||
"phase": "phase_0_bootstrap",
|
||||
"reason": "initial_state_created",
|
||||
}
|
||||
],
|
||||
tool_strategy_used=None,
|
||||
tool_round_count=0,
|
||||
max_tool_rounds=2,
|
||||
|
||||
86
backend/app/agents/transport/structured_io.py
Normal file
86
backend/app/agents/transport/structured_io.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Structured IO for typed input/output - Phase 10.2"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredInput:
|
||||
"""Structured input wrapper"""
|
||||
|
||||
skill_name: str
|
||||
parameters: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredOutput:
|
||||
"""Structured output wrapper"""
|
||||
|
||||
skill_name: str
|
||||
result: Any
|
||||
success: bool
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class StructuredIO:
|
||||
"""Handles structured input/output for agent communication"""
|
||||
|
||||
def parse_input(self, data: dict[str, Any]) -> StructuredInput:
|
||||
"""Parse structured input from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing skill_name, parameters, and metadata
|
||||
|
||||
Returns:
|
||||
StructuredInput instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Input data must be a dictionary")
|
||||
|
||||
skill_name = data.get("skill_name")
|
||||
if not skill_name:
|
||||
raise ValueError("Missing required field: skill_name")
|
||||
if not isinstance(skill_name, str):
|
||||
raise ValueError("skill_name must be a string")
|
||||
|
||||
parameters = data.get("parameters")
|
||||
if parameters is None:
|
||||
raise ValueError("Missing required field: parameters")
|
||||
if not isinstance(parameters, dict):
|
||||
raise ValueError("parameters must be a dictionary")
|
||||
|
||||
metadata = data.get("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError("metadata must be a dictionary")
|
||||
|
||||
return StructuredInput(skill_name=skill_name, parameters=parameters, metadata=metadata)
|
||||
|
||||
def format_output(self, output: StructuredOutput) -> dict[str, Any]:
|
||||
"""Format structured output to dictionary.
|
||||
|
||||
Args:
|
||||
output: StructuredOutput instance
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the output
|
||||
"""
|
||||
result = {
|
||||
"skill_name": output.skill_name,
|
||||
"result": output.result,
|
||||
"success": output.success,
|
||||
}
|
||||
|
||||
if output.error is not None:
|
||||
result["error"] = output.error
|
||||
|
||||
if output.metadata is not None:
|
||||
result["metadata"] = output.metadata
|
||||
|
||||
return result
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.agents.registry import load_builtin_registry_indexes
|
||||
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.skill import Skill
|
||||
@@ -17,14 +19,21 @@ from app.schemas.agent import (
|
||||
AgentCreate,
|
||||
AgentOut,
|
||||
AgentStats,
|
||||
AgentVisibilityCostByAgentOut,
|
||||
AgentVisibilityCostOut,
|
||||
AgentVisibilityCostSummaryOut,
|
||||
AgentVisibilityEvidenceOut,
|
||||
AgentVisibilityEventsResponse,
|
||||
AgentVisibilityEventOut,
|
||||
AgentVisibilityIsolationOut,
|
||||
AgentVisibilityRuntimeSummaryOut,
|
||||
AgentVisibilityTaskSummaryOut,
|
||||
AgentVisibilityThreadMessageOut,
|
||||
AgentVisibilityThreadOut,
|
||||
AgentVisibilityTopologyNodeOut,
|
||||
AgentVisibilityTopologyOut,
|
||||
AgentVisibilityToolGovernanceItemOut,
|
||||
AgentVisibilityToolGovernanceOut,
|
||||
AgentVisibilityVerifierOut,
|
||||
)
|
||||
from app.services.agent_service import _extract_continuity_snapshot
|
||||
@@ -153,12 +162,13 @@ def _build_topology_nodes(
|
||||
|
||||
root_agent_id = str(state.get("root_agent_id") or state.get("agent_id") or "") or None
|
||||
current_agent = str(state.get("current_agent") or "") or None
|
||||
parent_agent_id = str(state.get("parent_agent_id") or "") or None
|
||||
nodes: dict[str, AgentVisibilityTopologyNodeOut] = {}
|
||||
if root_agent_id:
|
||||
nodes[root_agent_id] = AgentVisibilityTopologyNodeOut(
|
||||
agent_id=root_agent_id,
|
||||
role=root_agent_id.split("-")[0],
|
||||
parent_agent_id=None,
|
||||
parent_agent_id=parent_agent_id if root_agent_id != state.get("agent_id") else None,
|
||||
source="root",
|
||||
task_count=task_counts.get(root_agent_id, 0),
|
||||
completed_task_count=completed_counts.get(root_agent_id, 0),
|
||||
@@ -185,6 +195,153 @@ def _build_topology_nodes(
|
||||
return list(nodes.values())
|
||||
|
||||
|
||||
def _estimate_runtime_cost(input_tokens: int, output_tokens: int) -> float | None:
|
||||
return estimate_token_cost(input_tokens, output_tokens)
|
||||
|
||||
|
||||
def _build_cost_summary(
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
conversation_id: str,
|
||||
) -> AgentVisibilityCostSummaryOut:
|
||||
input_tokens = int(state.get("input_tokens") or 0)
|
||||
output_tokens = int(state.get("output_tokens") or 0)
|
||||
estimated_cost = _estimate_runtime_cost(input_tokens, output_tokens)
|
||||
thresholds = coerce_cost_thresholds(state.get("cost_thresholds"))
|
||||
total_budget_warning = bool(state.get("budget_warning") or False) or is_cost_budget_warning(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
estimated_cost,
|
||||
thresholds,
|
||||
)
|
||||
|
||||
by_agent_items: list[AgentVisibilityCostByAgentOut] = []
|
||||
for agent_id, payload in dict(state.get("cost_by_agent") or {}).items():
|
||||
payload_dict = dict(payload or {})
|
||||
agent_input_tokens = int(payload_dict.get("input_tokens") or 0)
|
||||
agent_output_tokens = int(payload_dict.get("output_tokens") or 0)
|
||||
agent_estimated_cost = payload_dict.get("estimated_cost")
|
||||
if agent_estimated_cost is None:
|
||||
agent_estimated_cost = _estimate_runtime_cost(agent_input_tokens, agent_output_tokens)
|
||||
by_agent_items.append(
|
||||
AgentVisibilityCostByAgentOut(
|
||||
agent_id=str(payload_dict.get("agent_id") or agent_id),
|
||||
input_tokens=agent_input_tokens,
|
||||
output_tokens=agent_output_tokens,
|
||||
total_tokens=int(payload_dict.get("total_tokens") or (agent_input_tokens + agent_output_tokens)),
|
||||
estimated_cost=agent_estimated_cost,
|
||||
budget_warning=bool(payload_dict.get("budget_warning") or False),
|
||||
)
|
||||
)
|
||||
by_agent_items.sort(key=lambda item: item.total_tokens, reverse=True)
|
||||
|
||||
return AgentVisibilityCostSummaryOut(
|
||||
conversation_id=conversation_id,
|
||||
total=AgentVisibilityCostOut(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=input_tokens + output_tokens,
|
||||
estimated_cost=estimated_cost,
|
||||
budget_warning=total_budget_warning,
|
||||
),
|
||||
thresholds=thresholds,
|
||||
by_agent=by_agent_items,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool_governance(
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
conversation_id: str,
|
||||
) -> AgentVisibilityToolGovernanceOut:
|
||||
indexes = load_builtin_registry_indexes()
|
||||
tool_outcomes = [dict(item) for item in state.get("tool_outcomes") or [] if isinstance(item, dict)]
|
||||
usage_count_by_tool: dict[str, int] = {}
|
||||
last_result_preview_by_tool: dict[str, str | None] = {}
|
||||
for item in tool_outcomes:
|
||||
tool_name = str(item.get("tool_name") or "")
|
||||
if tool_name == "search_web":
|
||||
tool_name = "web_search"
|
||||
if not tool_name:
|
||||
continue
|
||||
usage_count_by_tool[tool_name] = usage_count_by_tool.get(tool_name, 0) + 1
|
||||
preview = item.get("result_preview")
|
||||
if isinstance(preview, str) and preview:
|
||||
last_result_preview_by_tool[tool_name] = preview
|
||||
|
||||
items = [
|
||||
AgentVisibilityToolGovernanceItemOut(
|
||||
capability_id=capability.capability_id,
|
||||
tool_name=capability.tool_name,
|
||||
permission_class=capability.permission_class.value,
|
||||
side_effect_scope=capability.side_effect_scope.value,
|
||||
supports_retry=capability.supports_retry,
|
||||
idempotent=capability.idempotent,
|
||||
safe_for_parallel_use=capability.safe_for_parallel_use,
|
||||
requires_confirmation=capability.requires_confirmation,
|
||||
usage_count=usage_count_by_tool.get(capability.tool_name, 0),
|
||||
last_result_preview=last_result_preview_by_tool.get(capability.tool_name),
|
||||
)
|
||||
for capability in indexes.capability_by_id.values()
|
||||
]
|
||||
items.sort(key=lambda item: (-item.usage_count, item.tool_name))
|
||||
|
||||
return AgentVisibilityToolGovernanceOut(
|
||||
conversation_id=conversation_id,
|
||||
total_tools=len(items),
|
||||
used_tools=sum(1 for item in items if item.usage_count > 0),
|
||||
items=items,
|
||||
upgrade_candidates=[
|
||||
"worktree_manager",
|
||||
"cost_inspector",
|
||||
"runtime_event_drilldown",
|
||||
"tool_policy_explorer",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _build_runtime_summary(
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
conversation_id: str,
|
||||
) -> AgentVisibilityRuntimeSummaryOut:
|
||||
tasks = [dict(item) for item in state.get("active_tasks") or []]
|
||||
task_results = [dict(item) for item in state.get("task_results") or []]
|
||||
topology_nodes = _build_topology_nodes(state, tasks, task_results)
|
||||
cost_summary = _build_cost_summary(state, conversation_id=conversation_id)
|
||||
input_tokens = cost_summary.total.input_tokens
|
||||
output_tokens = cost_summary.total.output_tokens
|
||||
recent_events_raw = [dict(item) for item in (state.get("event_trace") or [])[-10:]]
|
||||
isolation_mode = str(state.get("isolation_mode") or "none")
|
||||
|
||||
return AgentVisibilityRuntimeSummaryOut(
|
||||
conversation_id=conversation_id,
|
||||
execution_mode=state.get("execution_mode"),
|
||||
current_phase=state.get("current_phase"),
|
||||
current_checkpoint=state.get("current_checkpoint"),
|
||||
phase_history=list(state.get("phase_history") or []),
|
||||
checkpoint_history=list(state.get("checkpoint_history") or []),
|
||||
verifier=AgentVisibilityVerifierOut(
|
||||
conversation_id=conversation_id,
|
||||
status=state.get("verification_status"),
|
||||
summary=state.get("verification_summary"),
|
||||
evidence=list(state.get("verification_evidence") or []),
|
||||
),
|
||||
isolation=AgentVisibilityIsolationOut(
|
||||
mode=isolation_mode,
|
||||
isolation_id=state.get("isolation_id"),
|
||||
workspace_path=state.get("isolation_workspace_path"),
|
||||
parent_conversation_id=state.get("isolation_parent_conversation_id") or state.get("parent_conversation_id"),
|
||||
metadata=dict(state.get("isolation_metadata") or {}),
|
||||
),
|
||||
cost=cost_summary.total,
|
||||
topology_node_count=len(topology_nodes),
|
||||
active_task_count=len(tasks),
|
||||
completed_task_count=sum(1 for item in task_results if item.get("status") == "completed"),
|
||||
recent_events=[_coerce_event_payload(item) for item in recent_events_raw],
|
||||
)
|
||||
|
||||
|
||||
def record_agent_call(agent_id: str):
|
||||
_agent_call_counts[agent_id] = _agent_call_counts.get(agent_id, 0) + 1
|
||||
|
||||
@@ -475,6 +632,36 @@ async def get_visibility_verifier(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/visibility/runtime-summary", response_model=AgentVisibilityRuntimeSummaryOut)
|
||||
async def get_visibility_runtime_summary(
|
||||
conversation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||
return _build_runtime_summary(state, conversation_id=conversation_id)
|
||||
|
||||
|
||||
@router.get("/visibility/cost", response_model=AgentVisibilityCostSummaryOut)
|
||||
async def get_visibility_cost(
|
||||
conversation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||
return _build_cost_summary(state, conversation_id=conversation_id)
|
||||
|
||||
|
||||
@router.get("/visibility/tools", response_model=AgentVisibilityToolGovernanceOut)
|
||||
async def get_visibility_tools(
|
||||
conversation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||
return _build_tool_governance(state, conversation_id=conversation_id)
|
||||
|
||||
|
||||
@router.post("", response_model=AgentOut, status_code=201)
|
||||
async def create_agent(
|
||||
data: AgentCreate,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Plugin API 路由 - Phase 8.6"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -167,3 +170,53 @@ async def add_to_marketplace(plugin: dict[str, str]) -> dict[str, str]:
|
||||
_plugin_marketplace = [p for p in _plugin_marketplace if p.get("id") != plugin["id"]]
|
||||
_plugin_marketplace.append(plugin)
|
||||
return {"status": "added", "id": plugin["id"]}
|
||||
|
||||
|
||||
@_marketplace_router.post("/plugins/{plugin_id}/download", response_model=dict[str, str])
|
||||
async def download_plugin(plugin_id: str) -> dict[str, str]:
|
||||
"""从市场下载并安装插件"""
|
||||
# Find plugin in marketplace
|
||||
plugin = None
|
||||
for p in _plugin_marketplace:
|
||||
if p.get("id") == plugin_id:
|
||||
plugin = p
|
||||
break
|
||||
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Plugin '{plugin_id}' not found in marketplace"
|
||||
)
|
||||
|
||||
download_url = plugin.get("download_url")
|
||||
if not download_url:
|
||||
raise HTTPException(status_code=400, detail="Plugin has no download URL")
|
||||
|
||||
try:
|
||||
# Download the plugin archive
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
archive_content = response.content
|
||||
|
||||
# Extract to temp directory and install
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive_path = os.path.join(temp_dir, "plugin.zip")
|
||||
with open(archive_path, "wb") as f:
|
||||
f.write(archive_content)
|
||||
|
||||
extract_dir = os.path.join(temp_dir, "extracted")
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
# Install the plugin
|
||||
manager = get_plugin_manager()
|
||||
if manager.install(extract_dir):
|
||||
return {"status": "installed", "plugin_id": plugin_id}
|
||||
raise HTTPException(status_code=500, detail="Failed to install plugin")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Download failed: {str(e)}")
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=502, detail="Invalid plugin archive")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Installation failed: {str(e)}")
|
||||
|
||||
@@ -149,3 +149,73 @@ class AgentVisibilityVerifierOut(BaseModel):
|
||||
status: str | None = None
|
||||
summary: str | None = None
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentVisibilityIsolationOut(BaseModel):
|
||||
mode: str = "none"
|
||||
isolation_id: str | None = None
|
||||
workspace_path: str | None = None
|
||||
parent_conversation_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentVisibilityCostOut(BaseModel):
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
estimated_cost: float | None = None
|
||||
budget_warning: bool = False
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
class AgentVisibilityCostByAgentOut(BaseModel):
|
||||
agent_id: str
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
estimated_cost: float | None = None
|
||||
budget_warning: bool = False
|
||||
|
||||
|
||||
class AgentVisibilityCostSummaryOut(BaseModel):
|
||||
conversation_id: str
|
||||
total: AgentVisibilityCostOut
|
||||
thresholds: dict[str, float] = Field(default_factory=dict)
|
||||
by_agent: list[AgentVisibilityCostByAgentOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentVisibilityToolGovernanceItemOut(BaseModel):
|
||||
capability_id: str
|
||||
tool_name: str
|
||||
permission_class: str
|
||||
side_effect_scope: str
|
||||
supports_retry: bool = False
|
||||
idempotent: bool = False
|
||||
safe_for_parallel_use: bool = False
|
||||
requires_confirmation: bool = False
|
||||
usage_count: int = 0
|
||||
last_result_preview: str | None = None
|
||||
|
||||
|
||||
class AgentVisibilityToolGovernanceOut(BaseModel):
|
||||
conversation_id: str
|
||||
total_tools: int = 0
|
||||
used_tools: int = 0
|
||||
items: list[AgentVisibilityToolGovernanceItemOut] = Field(default_factory=list)
|
||||
upgrade_candidates: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentVisibilityRuntimeSummaryOut(BaseModel):
|
||||
conversation_id: str
|
||||
execution_mode: str | None = None
|
||||
current_phase: str | None = None
|
||||
current_checkpoint: str | None = None
|
||||
phase_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
checkpoint_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
verifier: AgentVisibilityVerifierOut
|
||||
isolation: AgentVisibilityIsolationOut
|
||||
cost: AgentVisibilityCostOut
|
||||
topology_node_count: int = 0
|
||||
active_task_count: int = 0
|
||||
completed_task_count: int = 0
|
||||
recent_events: list[AgentVisibilityEventOut] = Field(default_factory=list)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models.conversation import Conversation, Message
|
||||
from app.models.user import User
|
||||
from app.agents.graph import get_agent_graph
|
||||
from app.agents.context import set_current_user, clear_current_user
|
||||
from app.agents.skills.registry import get_skill_registry
|
||||
from app.services import memory_service
|
||||
from app.services.brain_service import BrainService
|
||||
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
||||
@@ -95,9 +96,8 @@ def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None
|
||||
]
|
||||
|
||||
if isinstance(error, BadRequestError):
|
||||
return (
|
||||
getattr(capabilities, "provider", None) not in {"openai", "claude"}
|
||||
and any(marker in error_text for marker in markers)
|
||||
return getattr(capabilities, "provider", None) not in {"openai", "claude"} and any(
|
||||
marker in error_text for marker in markers
|
||||
)
|
||||
|
||||
return any(marker in error_text for marker in markers)
|
||||
@@ -153,8 +153,23 @@ _CONTINUITY_SNAPSHOT_FIELDS = (
|
||||
"verification_status",
|
||||
"verification_summary",
|
||||
"verification_evidence",
|
||||
"isolation_mode",
|
||||
"isolation_id",
|
||||
"isolation_workspace_path",
|
||||
"isolation_parent_conversation_id",
|
||||
"isolation_metadata",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"estimated_cost",
|
||||
"budget_warning",
|
||||
"cost_by_agent",
|
||||
"cost_thresholds",
|
||||
"budget_state",
|
||||
"collaboration_budget_history",
|
||||
"current_phase",
|
||||
"phase_history",
|
||||
"current_checkpoint",
|
||||
"checkpoint_history",
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +181,11 @@ def _normalize_legacy_turn_context(turn_context: Any, current_agent: Any) -> dic
|
||||
active_sub_flow = normalized.pop("active_sub_flow", None)
|
||||
if isinstance(active_agent, str) and active_agent and "active_agent" not in normalized:
|
||||
normalized["active_agent"] = active_agent
|
||||
if isinstance(active_sub_flow, str) and active_sub_flow and "active_sub_commander" not in normalized:
|
||||
if (
|
||||
isinstance(active_sub_flow, str)
|
||||
and active_sub_flow
|
||||
and "active_sub_commander" not in normalized
|
||||
):
|
||||
normalized["active_sub_commander"] = active_sub_flow
|
||||
if not normalized.get("active_agent") and isinstance(current_agent, str) and current_agent:
|
||||
normalized["active_agent"] = current_agent
|
||||
@@ -342,11 +361,32 @@ class AgentService:
|
||||
"【当前时间】\n"
|
||||
f"- current_time_utc: {reference['current_time_iso']}\n"
|
||||
f"- current_date_utc: {reference['current_date_iso']}\n"
|
||||
"说明:解析‘今天/明天/后天/本周/下周’等相对时间时,请以 current_time_utc 为准。"
|
||||
"说明:解析'今天/明天/后天/本周/下周'等相对时间时,请以 current_time_utc 为准。"
|
||||
)
|
||||
return context, reference
|
||||
|
||||
async def _get_user_llm_config(self, user_id: str, model_name: str | None = None) -> dict | None:
|
||||
def build_skill_context(self, skill_names: list[str]) -> dict:
|
||||
"""构建 Skills 上下文
|
||||
|
||||
Args:
|
||||
skill_names: Skill 名称列表
|
||||
|
||||
Returns:
|
||||
包含 skills 上下文的字典
|
||||
"""
|
||||
registry = get_skill_registry()
|
||||
merged_context = registry.get_skill_context(skill_names)
|
||||
return {
|
||||
"skills_context": merged_context,
|
||||
"skills_metadata": {
|
||||
"skills": skill_names,
|
||||
"count": len(skill_names),
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_user_llm_config(
|
||||
self, user_id: str, model_name: str | None = None
|
||||
) -> dict | None:
|
||||
"""获取用户的 LLM 模型配置"""
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user or not user.llm_config:
|
||||
@@ -396,13 +436,15 @@ class AgentService:
|
||||
user_llm_config: dict | None,
|
||||
) -> dict[str, Any]:
|
||||
state = initial_state(user_id, conversation.id)
|
||||
state.update({
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
"user_llm_config": user_llm_config,
|
||||
})
|
||||
state.update(
|
||||
{
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
"user_llm_config": user_llm_config,
|
||||
}
|
||||
)
|
||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||
if previous_snapshot:
|
||||
state.update(previous_snapshot)
|
||||
@@ -464,6 +506,7 @@ class AgentService:
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
doc_svc = DocumentService(self.db)
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
@@ -529,7 +572,9 @@ class AgentService:
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
current_datetime_context, current_datetime_reference = self._build_current_datetime_context()
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
|
||||
state = await self._build_agent_state(
|
||||
user_id=user_id,
|
||||
@@ -542,7 +587,9 @@ class AgentService:
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
|
||||
yield self._build_progress_event("thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题")
|
||||
yield self._build_progress_event(
|
||||
"thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题"
|
||||
)
|
||||
|
||||
try:
|
||||
async for event in graph.astream_events(state, version="v2"):
|
||||
@@ -551,7 +598,13 @@ class AgentService:
|
||||
metadata = event.get("metadata", {})
|
||||
data = event.get("data", {})
|
||||
|
||||
if kind == "on_chain_start" and event_name in {"master", "schedule_planner", "executor", "librarian", "analyst"}:
|
||||
if kind == "on_chain_start" and event_name in {
|
||||
"master",
|
||||
"schedule_planner",
|
||||
"executor",
|
||||
"librarian",
|
||||
"analyst",
|
||||
}:
|
||||
stage_map = {
|
||||
"master": ("thinking", "Jarvis 正在理解请求"),
|
||||
"schedule_planner": ("planning", "Jarvis 正在编排日程"),
|
||||
@@ -559,9 +612,13 @@ class AgentService:
|
||||
"librarian": ("tool", "Jarvis 正在检索知识"),
|
||||
"analyst": ("thinking", "Jarvis 正在分析信息"),
|
||||
}
|
||||
stage, label = stage_map.get(event_name, ("thinking", "Jarvis 正在思考"))
|
||||
yield self._build_progress_event(stage, label, agent=event_name, step=label)
|
||||
|
||||
stage, label = stage_map.get(
|
||||
event_name, ("thinking", "Jarvis 正在思考")
|
||||
)
|
||||
yield self._build_progress_event(
|
||||
stage, label, agent=event_name, step=label
|
||||
)
|
||||
|
||||
elif kind == "on_tool_start":
|
||||
yield self._build_progress_event(
|
||||
"tool",
|
||||
@@ -570,7 +627,7 @@ class AgentService:
|
||||
tool_name=event_name,
|
||||
step=f"正在执行 {event_name}",
|
||||
)
|
||||
|
||||
|
||||
elif kind == "on_tool_end":
|
||||
tool_result = data.get("output")
|
||||
step = f"已完成 {event_name}"
|
||||
@@ -583,14 +640,16 @@ class AgentService:
|
||||
tool_name=event_name,
|
||||
step=step,
|
||||
)
|
||||
|
||||
|
||||
elif kind == "on_chat_model_stream":
|
||||
chunk = data.get("chunk")
|
||||
content = _coerce_event_text(getattr(chunk, "content", "") if chunk else "")
|
||||
content = _coerce_event_text(
|
||||
getattr(chunk, "content", "") if chunk else ""
|
||||
)
|
||||
if content:
|
||||
collected += content
|
||||
yield {"type": "chunk", "content": content}
|
||||
|
||||
|
||||
elif kind == "on_chain_end":
|
||||
output = data.get("output")
|
||||
final_resp = None
|
||||
@@ -605,7 +664,9 @@ class AgentService:
|
||||
|
||||
elif kind == "on_chat_model_end":
|
||||
output = data.get("output")
|
||||
final_content = _coerce_event_text(getattr(output, "content", "") if output else "")
|
||||
final_content = _coerce_event_text(
|
||||
getattr(output, "content", "") if output else ""
|
||||
)
|
||||
if final_content:
|
||||
final_text = final_content
|
||||
if final_text != collected:
|
||||
@@ -614,12 +675,16 @@ class AgentService:
|
||||
|
||||
except Exception as e:
|
||||
if _is_streaming_rejection_error(e, user_llm_config) and not collected:
|
||||
yield self._build_progress_event("responding", "Jarvis 正在生成回复", agent="master", step="fallback")
|
||||
yield self._build_progress_event(
|
||||
"responding", "Jarvis 正在生成回复", agent="master", step="fallback"
|
||||
)
|
||||
try:
|
||||
result_state = await graph.ainvoke(state)
|
||||
if isinstance(result_state, dict):
|
||||
state.update(result_state)
|
||||
fallback_content = result_state.get("final_response") or str(result_state.get("messages", [AIMessage(content="")])[-1].content)
|
||||
fallback_content = result_state.get("final_response") or str(
|
||||
result_state.get("messages", [AIMessage(content="")])[-1].content
|
||||
)
|
||||
collected = str(fallback_content)
|
||||
yield {"type": "chunk", "content": collected}
|
||||
except Exception:
|
||||
@@ -643,14 +708,24 @@ class AgentService:
|
||||
if collected:
|
||||
assistant_msg.content = collected
|
||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||
assistant_msg.attachments = ([{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}] if continuity_snapshot else None)
|
||||
conv.agent_state = ({
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
} if continuity_snapshot else None)
|
||||
assistant_msg.attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
await BrainService(self.db).create_event(
|
||||
user_id,
|
||||
**_build_assistant_event_payload(collected),
|
||||
@@ -728,12 +803,16 @@ class AgentService:
|
||||
importance_signal=1.0,
|
||||
)
|
||||
|
||||
memory_ctx = await memory_service.build_memory_context(self.db, user_id, conversation_id, message)
|
||||
memory_ctx = await memory_service.build_memory_context(
|
||||
self.db, user_id, conversation_id, message
|
||||
)
|
||||
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
graph = get_agent_graph()
|
||||
current_datetime_context, current_datetime_reference = self._build_current_datetime_context()
|
||||
current_datetime_context, current_datetime_reference = (
|
||||
self._build_current_datetime_context()
|
||||
)
|
||||
state = await self._build_agent_state(
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
@@ -745,7 +824,9 @@ class AgentService:
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
result_state = await graph.ainvoke(state)
|
||||
response_content = result_state.get("final_response") or str(result_state.get("messages", [AIMessage(content="")])[-1].content)
|
||||
response_content = result_state.get("final_response") or str(
|
||||
result_state.get("messages", [AIMessage(content="")])[-1].content
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("agent_chat_simple_failed")
|
||||
response_content = "抱歉,发生错误。"
|
||||
@@ -766,15 +847,27 @@ class AgentService:
|
||||
)
|
||||
|
||||
assistant_msg.content = response_content
|
||||
continuity_snapshot = _build_continuity_snapshot(result_state) if 'result_state' in locals() else None
|
||||
assistant_msg.attachments = ([{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}] if continuity_snapshot else None)
|
||||
conv.agent_state = ({
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
} if continuity_snapshot else None)
|
||||
continuity_snapshot = (
|
||||
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||
)
|
||||
assistant_msg.attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
]
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
conv.agent_state = (
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
if continuity_snapshot
|
||||
else None
|
||||
)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(assistant_msg)
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ from app.agents.graph import (
|
||||
_create_child_agent,
|
||||
_execute_tool_calls,
|
||||
_parse_json_action,
|
||||
_record_checkpoint,
|
||||
_record_interrupt,
|
||||
_record_recovery,
|
||||
_route_agent_from_user_query,
|
||||
_select_request_mode,
|
||||
_set_phase,
|
||||
_spawn_permission_for_role,
|
||||
_run_collaboration_flow,
|
||||
_run_sub_commander,
|
||||
@@ -78,8 +80,23 @@ def _base_state(message: str, user_llm_config: dict | None = None) -> dict:
|
||||
'verification_status': None,
|
||||
'verification_summary': None,
|
||||
'verification_evidence': [],
|
||||
'isolation_mode': 'none',
|
||||
'isolation_id': None,
|
||||
'isolation_workspace_path': None,
|
||||
'isolation_parent_conversation_id': None,
|
||||
'isolation_metadata': {},
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'estimated_cost': None,
|
||||
'budget_warning': False,
|
||||
'cost_by_agent': {},
|
||||
'cost_thresholds': {},
|
||||
'budget_state': None,
|
||||
'collaboration_budget_history': [],
|
||||
'current_phase': 'phase_0_bootstrap',
|
||||
'phase_history': [{'phase': 'phase_0_bootstrap', 'reason': 'initial_state_created'}],
|
||||
'current_checkpoint': 'bootstrap.initialized',
|
||||
'checkpoint_history': [{'checkpoint': 'bootstrap.initialized', 'phase': 'phase_0_bootstrap', 'reason': 'initial_state_created'}],
|
||||
'tool_strategy_used': None,
|
||||
'tool_round_count': 0,
|
||||
'max_tool_rounds': 2,
|
||||
@@ -310,6 +327,24 @@ def test_initial_state_sets_structured_continuity_defaults():
|
||||
assert state['clarification_context'] is None
|
||||
assert state['event_trace'] == []
|
||||
assert state['tool_outcomes'] == []
|
||||
assert state['current_phase'] == 'phase_0_bootstrap'
|
||||
assert state['current_checkpoint'] == 'bootstrap.initialized'
|
||||
assert state['phase_history'][-1]['phase'] == 'phase_0_bootstrap'
|
||||
assert state['checkpoint_history'][-1]['checkpoint'] == 'bootstrap.initialized'
|
||||
|
||||
|
||||
def test_set_phase_and_record_checkpoint_append_history():
|
||||
state = _base_state('test')
|
||||
|
||||
_set_phase(state, 'phase_1_routing', reason='entered_master')
|
||||
_record_checkpoint(state, 'routing.master_entered', reason='entered_master')
|
||||
|
||||
assert state['current_phase'] == 'phase_1_routing'
|
||||
assert state['current_checkpoint'] == 'routing.master_entered'
|
||||
assert state['phase_history'][-1]['phase'] == 'phase_1_routing'
|
||||
assert state['checkpoint_history'][-1]['checkpoint'] == 'routing.master_entered'
|
||||
assert 'agent.phase.changed' in [event['event_type'] for event in state['event_trace']]
|
||||
assert 'agent.checkpoint.recorded' in [event['event_type'] for event in state['event_trace']]
|
||||
|
||||
|
||||
def test_spawn_permission_for_role_uses_registry_policy():
|
||||
@@ -627,6 +662,15 @@ async def test_run_collaboration_flow_collects_task_results_and_verifies(monkeyp
|
||||
assert result['message_trace'][-1]['message_type'] == 'task_update'
|
||||
assert 'agent.created' in [event['event_type'] for event in result['event_trace']]
|
||||
assert 'agent.message.sent' in [event['event_type'] for event in result['event_trace']]
|
||||
assert 'agent.phase.changed' in [event['event_type'] for event in result['event_trace']]
|
||||
assert 'agent.checkpoint.recorded' in [event['event_type'] for event in result['event_trace']]
|
||||
assert result['current_phase'] == 'phase_4_visibility_and_verification'
|
||||
assert result['current_checkpoint'] == 'collaboration.completed'
|
||||
assert [entry['phase'] for entry in result['phase_history']][-3:] == [
|
||||
'phase_2_controlled_collaboration',
|
||||
'phase_3_dynamic_collaboration',
|
||||
'phase_4_visibility_and_verification',
|
||||
]
|
||||
assert 'agent.spawn.blocked' not in [event['event_type'] for event in result['event_trace']]
|
||||
assert result['spawned_agent_ids']
|
||||
assert all(not agent_id.startswith('blocked-') for agent_id in result['spawned_agent_ids'])
|
||||
@@ -637,6 +681,8 @@ async def test_master_node_enters_collaboration_mode_for_complex_multi_role_requ
|
||||
async def fake_collaboration_flow(state, user_query):
|
||||
state['execution_mode'] = 'collaboration'
|
||||
state['final_response'] = 'collaboration done'
|
||||
state['current_phase'] = 'phase_4_visibility_and_verification'
|
||||
state['current_checkpoint'] = 'collaboration.completed'
|
||||
state['messages'] = [*state.get('messages', []), AIMessage(content=state['final_response'])]
|
||||
return state
|
||||
|
||||
@@ -647,6 +693,31 @@ async def test_master_node_enters_collaboration_mode_for_complex_multi_role_requ
|
||||
|
||||
assert result['execution_mode'] == 'collaboration'
|
||||
assert result['final_response'] == 'collaboration done'
|
||||
assert result['current_phase'] == 'phase_4_visibility_and_verification'
|
||||
assert result['current_checkpoint'] == 'collaboration.completed'
|
||||
|
||||
|
||||
async def test_run_collaboration_flow_fallback_restores_routing_phase(monkeypatch):
|
||||
monkeypatch.setattr(graph_module, '_build_collaboration_tasks', lambda _user_query: [
|
||||
AgentTask(
|
||||
task_id='task-1',
|
||||
title='单任务',
|
||||
role=AgentRole.LIBRARIAN.value,
|
||||
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||
goal='检索资料',
|
||||
expected_evidence=[{'type': 'evidence'}],
|
||||
)
|
||||
])
|
||||
|
||||
state = _base_state('帮我搜一下资料')
|
||||
result = await _run_collaboration_flow(state, '帮我搜一下资料')
|
||||
|
||||
assert result['execution_mode'] == 'direct'
|
||||
assert result['routing_decision']['reason'] == 'collaboration_plan_fell_back'
|
||||
assert result['current_phase'] == 'phase_1_routing'
|
||||
assert result['current_checkpoint'] == 'routing.direct_resumed'
|
||||
assert result['checkpoint_history'][-2]['checkpoint'] == 'collaboration.fallback_to_direct'
|
||||
assert result['checkpoint_history'][-1]['checkpoint'] == 'routing.direct_resumed'
|
||||
|
||||
|
||||
async def test_master_node_returns_stable_reply_for_simple_greeting(monkeypatch):
|
||||
@@ -1404,6 +1475,78 @@ def test_build_verifier_hints_uses_capability_metadata():
|
||||
assert '提醒创建成功' in hints['result_preview']
|
||||
|
||||
|
||||
def test_prepare_isolation_context_selects_session_for_stateful_tools():
|
||||
state = _base_state('reminder request')
|
||||
|
||||
graph_module._prepare_isolation_context(
|
||||
state,
|
||||
role=AgentRole.SCHEDULE_PLANNER,
|
||||
sub_commander='schedule_planning',
|
||||
user_query='create a reminder for tomorrow morning and keep the intermediate state isolated',
|
||||
toolset=[FakeTool('create_reminder', 'ok')],
|
||||
)
|
||||
|
||||
assert state['isolation_mode'] == 'session'
|
||||
assert state['isolation_workspace_path'] is None
|
||||
assert state['isolation_metadata']['reason'] == 'stateful_or_non_parallel_tooling'
|
||||
assert state['event_trace'][-1]['event_type'] == 'agent.isolation.selected'
|
||||
|
||||
|
||||
def test_prepare_isolation_context_uses_worktree_for_repo_mutation_queries(monkeypatch):
|
||||
state = _base_state('fix repo build and create patch')
|
||||
|
||||
monkeypatch.setattr(
|
||||
graph_module,
|
||||
'prepare_worktree_isolation',
|
||||
lambda **kwargs: {
|
||||
'mode': 'worktree',
|
||||
'isolation_id': 'worktree-test',
|
||||
'workspace_path': '/tmp/jarvis/worktree-test',
|
||||
'parent_conversation_id': 'c1',
|
||||
'metadata': {
|
||||
'reason': kwargs['decision'].reason,
|
||||
'branch': 'jarvis/c1/executor-worktree-test',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
graph_module._prepare_isolation_context(
|
||||
state,
|
||||
role=AgentRole.EXECUTOR,
|
||||
sub_commander='executor_tasks',
|
||||
user_query='fix repo build and create patch for the failing tests',
|
||||
toolset=[FakeTool('create_task', 'ok')],
|
||||
)
|
||||
|
||||
assert state['isolation_mode'] == 'worktree'
|
||||
assert state['isolation_workspace_path'] == '/tmp/jarvis/worktree-test'
|
||||
assert state['isolation_metadata']['branch'] == 'jarvis/c1/executor-worktree-test'
|
||||
assert state['event_trace'][-1]['event_type'] == 'agent.isolation.selected'
|
||||
|
||||
|
||||
def test_record_response_usage_updates_state_cost_totals_and_budget_warning():
|
||||
state = _base_state('test')
|
||||
state['cost_thresholds'] = {'total_tokens': 100, 'estimated_cost': 0.0001}
|
||||
|
||||
graph_module._record_response_usage(
|
||||
state,
|
||||
AIMessage(
|
||||
content='ok',
|
||||
usage_metadata={'input_tokens': 60, 'output_tokens': 50, 'total_tokens': 110},
|
||||
),
|
||||
)
|
||||
|
||||
assert state['input_tokens'] == 60
|
||||
assert state['output_tokens'] == 50
|
||||
assert state['estimated_cost'] == 0.00093
|
||||
assert state['budget_warning'] is True
|
||||
assert state['cost_by_agent'][AgentRole.MASTER.value]['total_tokens'] == 110
|
||||
assert [event['event_type'] for event in state['event_trace']] == [
|
||||
'agent.cost.updated',
|
||||
'agent.cost.warning',
|
||||
]
|
||||
|
||||
|
||||
async def test_execute_tool_calls_records_schema_events_and_aggregate_summaries(monkeypatch):
|
||||
tool = FakeTool('create_reminder', '提醒创建成功: 开会 @ 2026-03-29 09:00')
|
||||
state = _base_state('test')
|
||||
|
||||
@@ -135,6 +135,45 @@ async def visibility_env(tmp_path):
|
||||
'verification_evidence': [
|
||||
{'task_id': 'task-1', 'status': 'passed', 'summary': 'Verified'}
|
||||
],
|
||||
'execution_mode': 'collaboration',
|
||||
'current_phase': 'phase_4_visibility_and_verification',
|
||||
'current_checkpoint': 'visibility.runtime_summary_ready',
|
||||
'phase_history': [
|
||||
{'phase': 'phase_0_bootstrap'},
|
||||
{'phase': 'phase_4_visibility_and_verification'},
|
||||
],
|
||||
'checkpoint_history': [
|
||||
{'checkpoint': 'bootstrap.initialized'},
|
||||
{'checkpoint': 'visibility.runtime_summary_ready'},
|
||||
],
|
||||
'input_tokens': 120,
|
||||
'output_tokens': 80,
|
||||
'budget_warning': True,
|
||||
'estimated_cost': 0.00156,
|
||||
'cost_thresholds': {'total_tokens': 150, 'estimated_cost': 0.001},
|
||||
'cost_by_agent': {
|
||||
'master': {
|
||||
'agent_id': 'master',
|
||||
'input_tokens': 60,
|
||||
'output_tokens': 20,
|
||||
'total_tokens': 80,
|
||||
'estimated_cost': 0.00048,
|
||||
'budget_warning': False,
|
||||
},
|
||||
'analyst-1234abcd': {
|
||||
'agent_id': 'analyst-1234abcd',
|
||||
'input_tokens': 60,
|
||||
'output_tokens': 60,
|
||||
'total_tokens': 120,
|
||||
'estimated_cost': 0.00108,
|
||||
'budget_warning': True,
|
||||
},
|
||||
},
|
||||
'isolation_mode': 'worktree',
|
||||
'isolation_id': 'iso-1',
|
||||
'isolation_workspace_path': '/tmp/jarvis/worktree-1',
|
||||
'isolation_parent_conversation_id': 'parent-conv-1',
|
||||
'isolation_metadata': {'branch': 'jarvis/test-worker'},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -396,6 +435,87 @@ async def test_visibility_verifier_returns_verdict(visibility_env):
|
||||
assert payload['evidence'][0]['task_id'] == ids['task_id']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_runtime_summary_returns_phase_cost_and_isolation_metadata(visibility_env):
|
||||
app, ids = visibility_env
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.get(
|
||||
'/api/agents/visibility/runtime-summary',
|
||||
params={'conversation_id': ids['conversation_id']},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['conversation_id'] == ids['conversation_id']
|
||||
assert payload['execution_mode'] == 'collaboration'
|
||||
assert payload['current_phase'] == 'phase_4_visibility_and_verification'
|
||||
assert payload['current_checkpoint'] == 'visibility.runtime_summary_ready'
|
||||
assert payload['verifier']['status'] == 'passed'
|
||||
assert payload['isolation']['mode'] == 'worktree'
|
||||
assert payload['isolation']['workspace_path'] == '/tmp/jarvis/worktree-1'
|
||||
assert payload['isolation']['metadata']['branch'] == 'jarvis/test-worker'
|
||||
assert payload['cost']['input_tokens'] == 120
|
||||
assert payload['cost']['output_tokens'] == 80
|
||||
assert payload['cost']['total_tokens'] == 200
|
||||
assert payload['cost']['estimated_cost'] == 0.00156
|
||||
assert payload['cost']['budget_warning'] is True
|
||||
assert payload['topology_node_count'] == 2
|
||||
assert payload['active_task_count'] == 1
|
||||
assert payload['completed_task_count'] == 1
|
||||
assert payload['recent_events'][0]['event_id'] == 'evt-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_cost_returns_totals_thresholds_and_agent_breakdown(visibility_env):
|
||||
app, ids = visibility_env
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.get(
|
||||
'/api/agents/visibility/cost',
|
||||
params={'conversation_id': ids['conversation_id']},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total']['input_tokens'] == 120
|
||||
assert payload['total']['output_tokens'] == 80
|
||||
assert payload['total']['total_tokens'] == 200
|
||||
assert payload['total']['budget_warning'] is True
|
||||
assert payload['thresholds']['total_tokens'] == 150
|
||||
assert payload['thresholds']['estimated_cost'] == 0.001
|
||||
assert payload['by_agent'][0]['agent_id'] == 'analyst-1234abcd'
|
||||
assert payload['by_agent'][0]['budget_warning'] is True
|
||||
assert payload['by_agent'][1]['agent_id'] == 'master'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_tools_returns_governance_metadata_and_usage_counts(visibility_env):
|
||||
app, ids = visibility_env
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.get(
|
||||
'/api/agents/visibility/tools',
|
||||
params={'conversation_id': ids['conversation_id']},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['total_tools'] >= 1
|
||||
assert payload['used_tools'] >= 1
|
||||
search_tool = next(item for item in payload['items'] if item['tool_name'] == 'search_web')
|
||||
assert search_tool['permission_class'] == 'external'
|
||||
assert search_tool['side_effect_scope'] == 'network'
|
||||
assert search_tool['usage_count'] == 1
|
||||
assert search_tool['last_result_preview'] == 'ok'
|
||||
assert payload['upgrade_candidates'] == [
|
||||
'worktree_manager',
|
||||
'cost_inspector',
|
||||
'runtime_event_drilldown',
|
||||
'tool_policy_explorer',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_events_reject_invalid_datetime(visibility_env):
|
||||
app, ids = visibility_env
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.models.conversation import Conversation, Message
|
||||
from app.models.memory import MemorySummary, UserMemory
|
||||
from app.models.user import User
|
||||
from app.services import agent_service, memory_service
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.agent_service import AgentService, _build_continuity_snapshot, _extract_continuity_snapshot
|
||||
from app.services.auth_service import get_password_hash
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
@@ -23,6 +23,32 @@ class FakeGraph:
|
||||
return {"final_response": "已记录你的请求。"}
|
||||
|
||||
|
||||
def test_continuity_snapshot_roundtrip_preserves_phase_and_checkpoint():
|
||||
payload = {
|
||||
"current_agent": "master",
|
||||
"current_phase": "phase_4_visibility_and_verification",
|
||||
"phase_history": [
|
||||
{"phase": "phase_0_bootstrap", "reason": "initial_state_created"},
|
||||
{"phase": "phase_4_visibility_and_verification", "reason": "verification_started"},
|
||||
],
|
||||
"current_checkpoint": "collaboration.completed",
|
||||
"checkpoint_history": [
|
||||
{"checkpoint": "bootstrap.initialized", "phase": "phase_0_bootstrap", "reason": "initial_state_created"},
|
||||
{"checkpoint": "collaboration.completed", "phase": "phase_4_visibility_and_verification", "reason": "collaboration_flow_finished"},
|
||||
],
|
||||
}
|
||||
|
||||
snapshot = _build_continuity_snapshot(payload)
|
||||
|
||||
assert snapshot is not None
|
||||
restored = _extract_continuity_snapshot({"kind": "agent_continuity_state", **snapshot})
|
||||
assert restored is not None
|
||||
assert restored["current_phase"] == "phase_4_visibility_and_verification"
|
||||
assert restored["current_checkpoint"] == "collaboration.completed"
|
||||
assert restored["phase_history"][-1]["phase"] == "phase_4_visibility_and_verification"
|
||||
assert restored["checkpoint_history"][-1]["checkpoint"] == "collaboration.completed"
|
||||
|
||||
|
||||
class FakeStreamingGraph:
|
||||
async def astream_events(self, state, version="v2"):
|
||||
yield {
|
||||
|
||||
BIN
data/jarvis.db
BIN
data/jarvis.db
Binary file not shown.
52
development-doc/README.md
Normal file
52
development-doc/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Development Doc
|
||||
|
||||
本目录用于持续记录 Jarvis 的规划与开发过程。
|
||||
|
||||
## 目录结构
|
||||
|
||||
### `plan/`
|
||||
用于记录中长期规划、升级方案、分阶段设计、专项计划。
|
||||
|
||||
建议内容:
|
||||
- 每个升级点的目标
|
||||
- 计划改动的模块和文件
|
||||
- 分阶段实施顺序
|
||||
- 风险与验收标准
|
||||
- 设计决策与取舍
|
||||
|
||||
当前文件:
|
||||
- `plan/README.md`
|
||||
- `plan/phase-0-current-state-and-target.md`
|
||||
- `plan/phase-1-safe-foundation.md`
|
||||
- `plan/phase-2-controlled-collaboration.md`
|
||||
- `plan/phase-3-dynamic-collaboration.md`
|
||||
- `plan/phase-4-visibility-and-isolation.md`
|
||||
|
||||
### `daily/`
|
||||
用于记录每日工作日志、当天进展、开发计划、执行情况、问题、决策和下一步安排。
|
||||
|
||||
建议内容:
|
||||
- 今日开发计划
|
||||
- 今日实际完成内容
|
||||
- 当前进度
|
||||
- 修改了哪些模块 / 文件
|
||||
- 当前阻塞点
|
||||
- 风险与临时决策
|
||||
- 下一步计划
|
||||
- 验证 / 测试情况
|
||||
|
||||
维护要求:
|
||||
- 开始做当天开发前,先写当天计划
|
||||
- 每完成一个关键步骤后,及时补充进度
|
||||
- 遇到阻塞、改方案、改优先级时,必须更新 daily
|
||||
- 一天结束前,补齐“完成情况 / 未完成 / 下一步”
|
||||
- 后续正式改代码时,要把 daily 作为持续更新的开发日志,而不是事后总结
|
||||
|
||||
当前文件:
|
||||
- `daily/2026-04-03.md`
|
||||
- `daily/2026-04-04.md`
|
||||
|
||||
补充约定:
|
||||
- 多天改造内容不要挤在同一个 daily 文档里
|
||||
- 每一天单独一个 daily 文件
|
||||
- 分步骤执行时,已完成项使用 `~~删除线~~` 标记
|
||||
203
development-doc/daily/2026-04-03.md
Normal file
203
development-doc/daily/2026-04-03.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 2026-04-03 工作日志
|
||||
|
||||
## 今日开发计划
|
||||
|
||||
### 今日目标
|
||||
|
||||
- ~~分析 `demo/` 下三个 agent 项目与 Jarvis 当前 agents 的差异~~
|
||||
- ~~明确 Jarvis 2.0 升级方向~~
|
||||
- ~~建立 development-doc 文档结构~~
|
||||
- ~~形成分阶段 plan 文档~~
|
||||
- ~~形成 2 天融合改造计划~~
|
||||
|
||||
### 今日计划拆分
|
||||
|
||||
1. ~~分析 demo 项目能力与设计重点~~
|
||||
2. ~~评估 Jarvis 当前 agents 的优势与短板~~
|
||||
3. ~~输出 Jarvis 2.0 总体升级思路~~
|
||||
4. ~~建立 `development-doc/plan` 与 `development-doc/daily`~~
|
||||
5. ~~将 plan 拆成 phase 文档~~
|
||||
6. ~~输出 2 天融合改造计划~~
|
||||
|
||||
### Day 1 工作内容
|
||||
|
||||
#### Day 1 目标
|
||||
|
||||
- ~~完成 demo 项目分析~~
|
||||
- ~~完成 Jarvis 当前能力差距判断~~
|
||||
- ~~完成 `development-doc/` 目录搭建~~
|
||||
- ~~完成 phase 文档拆分~~
|
||||
- ~~完成 2 天融合计划初稿~~
|
||||
|
||||
#### Day 1 分步骤执行
|
||||
|
||||
1. ~~分析 `demo/swarm-ide-chore-specs-mvp`~~
|
||||
2. ~~分析 `demo/claude-code-cli-master`~~
|
||||
3. ~~分析 `demo/claw-code-main`~~
|
||||
4. ~~梳理 Jarvis 当前 agents 架构优势与短板~~
|
||||
5. ~~输出 `plan/README.md` 与 phase 文档~~
|
||||
6. ~~输出 `2026-04-03-jarvis-agents-2-day-integration-plan.md`~~
|
||||
7. ~~建立并整理 `daily/` 日志结构~~
|
||||
|
||||
#### Day 1 完成标准
|
||||
|
||||
- ~~文档结构齐全~~
|
||||
- ~~分阶段计划可读~~
|
||||
- ~~两天改造路线明确~~
|
||||
- ~~daily 可持续维护~~
|
||||
|
||||
> Day 2 内容已拆分到 `daily/2026-04-04.md`
|
||||
|
||||
---
|
||||
|
||||
## 今日实际完成
|
||||
|
||||
1. 分析了以下 demo 项目:
|
||||
- `demo/swarm-ide-chore-specs-mvp`
|
||||
- `demo/claude-code-cli-master`
|
||||
- `demo/claw-code-main`
|
||||
|
||||
2. 梳理了 Jarvis 当前 agent 架构的优势与短板:
|
||||
- 当前强项:分层路由、业务导向、continuity、fallback、测试基础
|
||||
- 当前短板:动态协作不足、缺少 verifier、缺少 task/runtime、可观察性不足
|
||||
|
||||
3. 输出并整理了 Jarvis 规划文档结构:
|
||||
- `development-doc/README.md`
|
||||
- `development-doc/plan/README.md`
|
||||
- `development-doc/plan/phase-0-current-state-and-target.md`
|
||||
- `development-doc/plan/phase-1-safe-foundation.md`
|
||||
- `development-doc/plan/phase-2-controlled-collaboration.md`
|
||||
- `development-doc/plan/phase-3-dynamic-collaboration.md`
|
||||
- `development-doc/plan/phase-4-visibility-and-isolation.md`
|
||||
- `development-doc/plan/2026-04-03-jarvis-agents-2-day-integration-plan.md`
|
||||
|
||||
4. 建立并整理了 daily 目录:
|
||||
- `development-doc/daily/2026-04-03.md`
|
||||
|
||||
---
|
||||
|
||||
## 当前进度
|
||||
|
||||
### 文档规划进度
|
||||
|
||||
- demo 分析:已完成
|
||||
- 总体升级方向:已完成
|
||||
- plan 目录搭建:已完成
|
||||
- 分阶段 plan:已完成
|
||||
- 2 天融合计划:已完成
|
||||
- daily 规范:已完成
|
||||
- Day 3 清单与验收文档:已更新
|
||||
- Day 4 清单:已更新
|
||||
|
||||
### 代码改造进度
|
||||
|
||||
- 已完成 Day 1 / Day 2 底座与协作闭环
|
||||
- 已完成 Day 3 最小受限动态协作 runtime
|
||||
- 已补齐 registry spawn policy、graph spawn guardrail、message trace、interrupt / recovery 最小闭环
|
||||
- Phase 1-3 核心功能已基本落地
|
||||
- Day 4 聚焦 Phase 4 可视化与隔离执行能力
|
||||
|
||||
---
|
||||
|
||||
## 今日修改的模块 / 文件
|
||||
|
||||
### 新增 / 更新的开发文档
|
||||
|
||||
- `development-doc/README.md`
|
||||
- `development-doc/plan/README.md`
|
||||
- `development-doc/plan/phase-0-current-state-and-target.md`
|
||||
- `development-doc/plan/phase-1-safe-foundation.md`
|
||||
- `development-doc/plan/phase-2-controlled-collaboration.md`
|
||||
- `development-doc/plan/phase-3-dynamic-collaboration.md`
|
||||
- `development-doc/plan/phase-4-visibility-and-isolation.md`
|
||||
- `development-doc/plan/2026-04-03-jarvis-agents-2-day-integration-plan.md`
|
||||
- `development-doc/daily/2026-04-03.md`
|
||||
|
||||
### 本轮补充分析与改造涉及的代码文件
|
||||
|
||||
- `backend/app/agents/state.py`
|
||||
- `backend/app/agents/graph.py`
|
||||
- `backend/app/agents/prompts.py`
|
||||
- `backend/app/agents/registry/models.py`
|
||||
- `backend/app/agents/registry/builtins.py`
|
||||
- `backend/app/agents/registry/indexes.py`
|
||||
- `backend/app/agents/tools/__init__.py`
|
||||
- `backend/tests/backend/app/agents/test_graph.py`
|
||||
- `backend/tests/backend/app/agents/test_registry.py`
|
||||
|
||||
---
|
||||
|
||||
## 今日结论
|
||||
|
||||
### 从 demo 项目吸收的核心方向
|
||||
|
||||
- 从 Swarm-IDE 学:动态通信原语、可观察性、协作拓扑
|
||||
- 从 Claude Code CLI 学:coordinator / task / verifier 的平台化编排
|
||||
- 从 Claw Code 学:runtime 分层、工具注册表、权限模型
|
||||
|
||||
### Jarvis 总体升级方向
|
||||
|
||||
Jarvis 不应直接变成完全自由的 swarm,而应升级为:
|
||||
|
||||
- 受控的动态协作运行时
|
||||
|
||||
原则:
|
||||
|
||||
- 简单请求继续走当前稳定路径
|
||||
- 复杂请求才进入协作模式
|
||||
|
||||
---
|
||||
|
||||
## 当前阻塞点
|
||||
|
||||
- 暂无明显代码层阻塞
|
||||
- 当前主要待办是完成定向回归测试并收尾验收
|
||||
|
||||
---
|
||||
|
||||
## 风险与临时决策
|
||||
|
||||
### 当前风险
|
||||
|
||||
- 不要一开始就引入无限动态 agent
|
||||
- 不要直接替换现有 graph 主路径
|
||||
- 应优先保持 reminder/task/search 等现有业务稳定
|
||||
- 新能力必须配套测试和约束策略
|
||||
|
||||
### 当前决策
|
||||
|
||||
1. 采用受限动态协作,而不是自由 swarm
|
||||
2. 通过 registry 固化 spawn role policy,再由 graph 在运行时执行权限校验
|
||||
3. interrupt / recovery 先落最小闭环,优先保证 direct 主路径稳定
|
||||
4. daily 后续必须作为开发过程中的持续更新日志使用
|
||||
|
||||
---
|
||||
|
||||
## 验证 / 测试情况
|
||||
|
||||
- 已补充 Day 3 相关 runtime / registry / graph 回归测试
|
||||
- 已更新 Day 3 执行清单与 daily 状态
|
||||
- 正在执行定向 pytest 验证,重点覆盖 `test_graph.py` 与 `test_registry.py`
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. 完成 Day 3 定向回归测试
|
||||
2. 若有失败,修正 runtime / test 偏差
|
||||
3. 统一整理 Day 3 最终验收结论
|
||||
4. 启动 Day 4:Phase 4 可视化 API 实现
|
||||
5. 设计隔离执行最小方案
|
||||
|
||||
---
|
||||
|
||||
## 每日维护要求
|
||||
|
||||
后续正式进入改造阶段后,本文件需要持续更新:
|
||||
|
||||
1. 开始开发前更新“今日开发计划”
|
||||
2. 完成一个阶段性步骤后更新“当前进度”
|
||||
3. 变更方案时更新“风险与临时决策”
|
||||
4. 出现问题时更新“当前阻塞点”
|
||||
5. 每次完成验证后更新“验证 / 测试情况”
|
||||
6. 一天结束前补齐“已完成 / 未完成 / 下一步计划”
|
||||
116
development-doc/daily/2026-04-04.md
Normal file
116
development-doc/daily/2026-04-04.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 2026-04-04 工作日志
|
||||
|
||||
## 今日开发计划
|
||||
|
||||
### 今日目标
|
||||
|
||||
- 巩固 `Phase 4` 已完成的可见性最小闭环
|
||||
- 把 runtime summary 接到 Agents 页面
|
||||
- 为后续 90 分路径明确 isolation / cost / operator surface 升级项
|
||||
- 保持 reminder / task / search 主路径稳定
|
||||
|
||||
### 今日计划拆分
|
||||
|
||||
1. 新增 `backend/app/agents/api/visibility.py` 可见性 API 模块
|
||||
2. 实现 event stream API
|
||||
3. 实现协作链路拓扑查询 API
|
||||
4. 实现 task 执行证据查询 API
|
||||
5. 实现 message thread 查询 API
|
||||
6. 实现 verifier 结果查询 API
|
||||
7. 设计隔离执行最小方案
|
||||
8. 补测试并验证主流程
|
||||
|
||||
### Day 4 工作内容
|
||||
|
||||
#### Day 4 目标
|
||||
|
||||
- 完成 `Phase 4` 可见性 API 最小闭环
|
||||
- 完成 runtime summary API 与前端 Agents 页面首屏接入
|
||||
- 为后续完整隔离执行与成本治理预留接口
|
||||
- 保证已有路径测试不回退
|
||||
|
||||
#### Day 4 分步骤执行
|
||||
|
||||
1. 新增 `backend/app/agents/api/visibility.py` 及各可见性 API
|
||||
2. `GET /agents/visibility/events` - event stream 按条件过滤
|
||||
3. `GET /agents/visibility/topology` - 协作拓扑视图
|
||||
4. `GET /agents/visibility/tasks/{task_id}/evidence` - task 执行证据
|
||||
5. `GET /agents/visibility/threads/{thread_id}/messages` - thread 消息流
|
||||
6. `GET /agents/visibility/verifier` - verifier 验收结论
|
||||
7. 在 `development-doc/plan/phase-4-visibility-and-isolation.md` 补充隔离执行设计方案
|
||||
8. 补 `test_visibility_api.py` 及主流程回归测试
|
||||
|
||||
#### Day 4 完成标准
|
||||
|
||||
- event stream API 可按 conversation_id / thread_id / agent_id 过滤
|
||||
- topology API 可返回协作拓扑视图
|
||||
- evidence API 可返回 task 执行证据链
|
||||
- thread API 可重建消息流向
|
||||
- verifier API 可返回验收结论
|
||||
- 隔离执行设计方案可落地
|
||||
- 现有主流程测试继续通过
|
||||
|
||||
---
|
||||
|
||||
## 今日实际完成
|
||||
|
||||
- 分析了 Jarvis 现有代码实现状态(`graph.py`、`state.py`、`verifier.py`、`registry/models.py`、`schemas/`)
|
||||
- 确认 Phase 1-3 核心功能已基本落地:task schema、event schema、verifier、tool metadata、collaboration flow、interrupt/recovery、message trace
|
||||
- 识别了 Phase 4(可视化与隔离执行)待实现内容
|
||||
- 在 `2026-04-03-jarvis-agents-5-day-work-checklist.md` 中新增了 Day 4 任务清单
|
||||
|
||||
---
|
||||
|
||||
## 当前进度
|
||||
|
||||
### 代码改造进度(Phase 1-3)
|
||||
|
||||
- ✅ task schema / event schema 已完整
|
||||
- ✅ verifier 模块已独立
|
||||
- ✅ state.py 已包含 collaboration 全部字段
|
||||
- ✅ registry/models.py 已补充 tool metadata
|
||||
- ✅ graph.py 已接入 event trace、verifier 调用、collaboration flow
|
||||
- ✅ interrupt / recovery 最小闭环已实现
|
||||
- ✅ message trace 已实现
|
||||
|
||||
### Day 4 待启动
|
||||
|
||||
- 待实现可见性 API(event stream、topology、evidence、thread、verifier)
|
||||
- 待设计隔离执行方案
|
||||
- 待补可视化 API 测试
|
||||
|
||||
---
|
||||
|
||||
## 今日修改的模块 / 文件
|
||||
|
||||
- 待更新
|
||||
|
||||
---
|
||||
|
||||
## 当前阻塞点
|
||||
|
||||
- 待开发时更新
|
||||
|
||||
---
|
||||
|
||||
## 风险与临时决策
|
||||
|
||||
- 不直接重写 graph 主路径
|
||||
- verifier 优先以 helper 形式接入
|
||||
- 先补底座,不直接做自由 swarm
|
||||
|
||||
---
|
||||
|
||||
## 验证 / 测试情况
|
||||
|
||||
- 待更新
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. 实现 `visibility.py` 可见性 API 模块
|
||||
2. 按顺序实现 event stream、topology、evidence、thread、verifier API
|
||||
3. 设计隔离执行最小方案
|
||||
4. 补 `test_visibility_api.py` 测试
|
||||
5. 跑测试验证主流程不回退
|
||||
@@ -1,102 +0,0 @@
|
||||
# Jarvis Agents 2 天工作计划(可勾选执行版)
|
||||
|
||||
日期:2026-04-03
|
||||
状态:执行清单
|
||||
适用范围:基于 `phase-0` ~ `phase-4` 及现有 2 天融合方案整理
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 完成前使用 `- [ ]`
|
||||
- 完成后改成 `- [x]`
|
||||
- Day 2 默认依赖 Day 1 的核心底座完成后再推进
|
||||
|
||||
---
|
||||
|
||||
## Day 1:补底座,完成 Phase 1 最小闭环
|
||||
|
||||
Day 1 目标:先把 Jarvis 从“只有静态路由”补成“有任务结构、有事件结构、有 verifier、有工具治理信息”的可扩展底座,同时不破坏当前 direct 主路径。
|
||||
|
||||
- [x] 新增最小 `task schema`
|
||||
改造内容:新增 `backend/app/agents/schemas/task.py`,统一 `task_id`、`title`、`status`、`owner_agent_id`、`evidence`、`result_summary`,并补 `role`、`goal`、`expected_evidence`、`created_at`、`updated_at`;状态固定为 `pending`、`in_progress`、`completed`、`failed`、`blocked`。
|
||||
|
||||
- [x] 新增最小 `event schema`
|
||||
改造内容:新增 `backend/app/agents/schemas/event.py`,统一 `event_id`、`event_type`、`timestamp`、`conversation_id`、`agent_id`、`sub_commander_id`、`task_id`、`payload`、`severity`;首批事件类型覆盖 `agent.tool.start`、`agent.tool.result`、`agent.verify.started`、`agent.verify.completed`、`agent.error`。
|
||||
|
||||
- [x] 扩展 `backend/app/agents/state.py` 的运行时字段
|
||||
改造内容:新增 `execution_mode`、`verification_status`、`verification_summary`、`verification_evidence`、`active_tasks`、`task_results`、`event_trace`、`budget_state`;默认值保持兼容 `initial_state()`,不替换现有 `pending_tasks`、`completed_tasks`、`tool_calls`。
|
||||
|
||||
- [x] 扩展 capability / tool metadata 模型
|
||||
改造内容:在 `backend/app/agents/registry/models.py` 增加 `permission_class`、`side_effect_scope`、`supports_retry`、`idempotent`、`safe_for_parallel_use`、`requires_confirmation`;至少先固化 `read` / `write` / `external` 和 `none` / `local_state` / `db_write` / `network` 两组枚举语义。
|
||||
|
||||
- [x] 回填 builtin tools 的静态 metadata
|
||||
改造内容:在 `backend/app/agents/registry/builtins.py` 和需要的 `backend/app/agents/tools/__init__.py` 中,把 search / retrieval 类工具标成偏 `read`,create / update 类工具标成偏 `write`,外部检索类工具标成 `external`,并补充是否可重试、是否幂等、是否适合并行等标记。
|
||||
|
||||
- [x] 新增 verifier 角色定义
|
||||
改造内容:在 `backend/app/agents/prompts.py` 增加 verifier prompt,明确 verifier 只负责验收,不负责重新规划;验收点聚焦“是否真正满足请求”“是否有明确证据”“是否把失败伪装成成功”。
|
||||
|
||||
- [x] 落地 verifier 模块
|
||||
改造内容:新增 `backend/app/agents/verifier.py`,支持 `passed`、`failed`、`skipped` 三类最小结论,先服务于工具调用后的复杂输出、知识检索结果和分析型汇总输出,不接管纯闲聊路径。
|
||||
|
||||
- [x] 在 `backend/app/agents/graph.py` 接入最小 event trace 与 verifier helper
|
||||
改造内容:给 `_execute_tool_calls()` 增加 tool start / result / error 事件写入;给收尾阶段增加 verifier helper 调用;给 `_run_sub_commander()` 增加 task result 摘要写入,但暂时不重构主图为完整协作编排图。
|
||||
|
||||
- [x] 补 Phase 1 单元测试与回归测试
|
||||
改造内容:新增 `backend/tests/backend/app/agents/test_agent_schemas.py`、`backend/tests/backend/app/agents/test_verifier.py`,并扩展 `test_graph.py`,覆盖 state 兼容性、schema 合法性、tool metadata 存在性、verifier 判定、主流程不回退。
|
||||
|
||||
- [x] 完成 Day 1 验收
|
||||
改造内容:确认 reminder / task / search 主流程继续通过;确认 verifier 已能独立运行;确认 event schema 与 task schema 已落代码;确认 direct 仍是默认主路径;确认未引入动态 `create_agent`、message bus 全链路和 UI。
|
||||
|
||||
---
|
||||
|
||||
## Day 2:引入最小协作能力,完成 Phase 2 雏形
|
||||
|
||||
Day 2 目标:在 Day 1 底座稳定的基础上,给 Jarvis 增加“复杂请求可拆分、可分配、可回收、可验收”的最小受控协作能力,但仍然不进入自由 swarm。
|
||||
|
||||
- [ ] 增加 `request_mode_selector`
|
||||
改造内容:在 `backend/app/agents/graph.py` 中增加 direct / collaboration 模式选择逻辑;简单请求继续走旧路径,只有明显多步骤、跨领域、需要多角色配合的请求才进入 collaboration mode。
|
||||
|
||||
- [ ] 新增 coordinator prompt
|
||||
改造内容:在 `backend/app/agents/prompts.py` 中定义 coordinator 角色,职责限定为“判断是否拆解”“输出 2~4 个清晰子任务”“分配角色建议”“汇总任务结果”;明确禁止无限递归拆分。
|
||||
|
||||
- [ ] 新增最小 task decomposition 结构
|
||||
改造内容:基于 Day 1 的 task schema 扩展最小拆分结构,至少输出 `task_id`、`title`、`role`、`goal`、`expected_evidence`,让复杂请求能以结构化任务列表进入后续执行。
|
||||
|
||||
- [ ] 增加 role -> existing agent assignment
|
||||
改造内容:先复用当前已有 top-level agent,不新增独立 worker runtime;把 schedule 类任务映射给 `schedule_planner`,retrieval 类任务映射给 `librarian`,analysis 类任务映射给 `analyst`,execution 类任务映射给 `executor`。
|
||||
|
||||
- [ ] 建立统一 task result 回收结构
|
||||
改造内容:约束每个角色统一返回 `task_id`、`status`、`summary`、`evidence`、`next_action`(可选),并把结果写回 `task_results`,避免最终结果继续依赖单点硬编码拼接。
|
||||
|
||||
- [ ] 让 verifier 强制参与协作结果收尾
|
||||
改造内容:在 collaboration mode 下,所有复杂请求返回前都必须经过 verifier;verifier 有权拒绝证据不足、结果不完整、子任务未闭环的响应。
|
||||
|
||||
- [ ] 补 Phase 2 协作测试与回归测试
|
||||
改造内容:覆盖复杂请求拆分测试、角色分配测试、task result 汇总测试、verifier 拒绝不完整结果测试,并再次确认 direct 模式原有流程不回退。
|
||||
|
||||
- [ ] 完成 Day 2 验收
|
||||
改造内容:确认 graph 已能区分 direct / collaboration;确认复杂请求可拆成 2~4 个子任务;确认每个子任务有 owner 和 evidence;确认最终答案基于 task result 汇总;确认系统仍未进入无限动态 agent 模式。
|
||||
|
||||
---
|
||||
|
||||
## 这 2 天明确不做
|
||||
|
||||
- 不做动态 `create_agent`
|
||||
- 不做 parent / child agent tree
|
||||
- 不做内部消息线程长期态管理
|
||||
- 不做可视化调试面板
|
||||
- 不做 event stream API
|
||||
- 不做 worktree / 隔离执行
|
||||
- 不做自由蜂群式协作
|
||||
|
||||
---
|
||||
|
||||
## 2 天结束后的预期状态
|
||||
|
||||
- [ ] 已具备 `direct` / `collaboration` 双模式入口
|
||||
- [ ] 已具备 verifier 独立验收层
|
||||
- [ ] 已具备 task schema / event schema / tool metadata 底座
|
||||
- [ ] 已具备 coordinator 雏形、任务拆分、角色分配、结果回收
|
||||
- [ ] 当前 reminder / task / search 主路径无明显回退
|
||||
- [ ] 后续可以继续推进 Phase 3 的受限动态协作,而不是返工 Phase 1 / Phase 2 底座
|
||||
@@ -150,14 +150,14 @@
|
||||
- [x] 创建内存版 PluginMarketplace (in-memory)
|
||||
- [x] 实现 search() — GET `/api/marketplace/plugins`
|
||||
- [x] 实现 get_plugin() — GET `/api/marketplace/plugins/{id}`
|
||||
- [ ] 实现 download_plugin()
|
||||
- [x] 实现 download_plugin() — POST `/api/marketplace/plugins/{id}/download`
|
||||
|
||||
### 8.5 内置插件
|
||||
|
||||
- [ ] 创建 `plugins/builtins/code_helper/` — lint, format, explain_code
|
||||
- [ ] 创建 `plugins/builtins/git_helper/` — git_status, git_log, git_diff
|
||||
- [ ] 创建 `plugins/builtins/web_helper/` — fetch_url, parse_html
|
||||
- [ ] 创建 `plugins/builtins/file_organizer/` — organize_files, cleanup_duplicates
|
||||
- [x] 创建 `plugins/builtins/code_helper/` — lint, format, explain_code
|
||||
- [x] 创建 `plugins/builtins/git_helper/` — git_status, git_log, git_diff
|
||||
- [x] 创建 `plugins/builtins/web_helper/` — fetch_url, parse_html
|
||||
- [x] 创建 `plugins/builtins/file_organizer/` — organize_files, cleanup_duplicates
|
||||
|
||||
### 8.6 API
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
- [x] 插件的工具和 Hook 正确注册
|
||||
- [x] 插件的工具和 Hook 正确注销
|
||||
- [x] 插件无法访问未授权资源
|
||||
- [ ] 插件加载时间 < 1s
|
||||
- [x] 插件加载时间 < 1s (built-in plugins)
|
||||
|
||||
---
|
||||
|
||||
@@ -214,16 +214,16 @@
|
||||
|
||||
### 9.4 内置 Skills
|
||||
|
||||
- [ ] 创建 `backend/app/agents/skills/bundled.py` — BUNDLED_SKILLS
|
||||
- [ ] 实现 code-analysis skill
|
||||
- [ ] 实现 git-helper skill
|
||||
- [ ] 实现 web-research skill
|
||||
- [ ] 实现 file-management skill
|
||||
- [ ] 实现 task-planning skill
|
||||
- [x] 创建 `backend/app/agents/skills/bundled.py` — BUNDLED_SKILLS
|
||||
- [x] 实现 code-analysis skill
|
||||
- [x] 实现 git-helper skill
|
||||
- [x] 实现 web-research skill
|
||||
- [x] 实现 file-management skill
|
||||
- [x] 实现 task-planning skill
|
||||
|
||||
### 9.5 Agent 集成
|
||||
|
||||
- [ ] AgentService.build_skill_context()
|
||||
- [x] AgentService.build_skill_context()
|
||||
- [ ] Skill 上下文注入 Agent prompt
|
||||
- [ ] Skill 触发检测
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
|
||||
- [x] 能加载 local_skills_dir 下的所有 SKILL.md
|
||||
- [x] 能从 MCP 服务器发现和加载 Skills
|
||||
- [ ] 内置 Skills 默认加载
|
||||
- [x] 内置 Skills 默认加载
|
||||
- [ ] Skill 内容正确注入 Agent prompt
|
||||
|
||||
---
|
||||
@@ -271,7 +271,7 @@
|
||||
|
||||
### 10.2 远程传输层
|
||||
|
||||
- [ ] 创建 `backend/app/agents/transport/structured_io.py` — StructuredIO
|
||||
- [x] 创建 `backend/app/agents/transport/structured_io.py` — StructuredIO
|
||||
- [x] 创建 `backend/app/agents/transport/remote.py` — RemoteTransport
|
||||
- [x] 实现 send_response()
|
||||
- [x] 实现 send_event()
|
||||
@@ -292,8 +292,8 @@
|
||||
### 10.4 后台任务系统
|
||||
|
||||
- [x] 创建 `backend/app/agents/background/manager.py` — BackgroundTaskManager
|
||||
- [ ] 创建 `backend/app/agents/background/scheduler.py`
|
||||
- [ ] 创建 `backend/app/agents/background/executor.py`
|
||||
- [x] 创建 `backend/app/agents/background/scheduler.py`
|
||||
- [x] 创建 `backend/app/agents/background/executor.py`
|
||||
- [x] 实现 submit_task()
|
||||
- [x] 实现 cancel_task()
|
||||
- [x] 实现 get_task_status()
|
||||
@@ -301,7 +301,7 @@
|
||||
|
||||
### 10.5 协调整合
|
||||
|
||||
- [ ] 创建/修改 `backend/app/agents/coordinator.py`
|
||||
- [x] 创建/修改 `backend/app/agents/coordinator.py`
|
||||
- [ ] Team 协作与现有 graph 集成
|
||||
- [ ] 远程传输与现有 service 集成
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
- [x] 可以创建和管理 Agent 团队
|
||||
- [x] 任务能正确分配给合适的成员
|
||||
- [x] 能收集和聚合多成员的结果
|
||||
- [ ] 支持结构化的输入输出格式
|
||||
- [x] 支持结构化的输入输出格式
|
||||
- [x] 支持远程 Agent 通信
|
||||
- [x] 支持复杂的会话层级和状态管理
|
||||
- [x] 支持定时和异步后台任务
|
||||
|
||||
171
development-doc/plan/code-update/README.md
Normal file
171
development-doc/plan/code-update/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 代码指挥官 (Code Commander) 实施计划索引
|
||||
|
||||
本目录用于存放代码指挥官模块的分阶段规划文档。
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、实施顺序 |
|
||||
| `phase-1-infrastructure.md` | 基础设施:State、Prompt、注册 |
|
||||
| `phase-2-execution-engine.md` | 执行引擎:AI Adapter、沙盒、直接执行 |
|
||||
| `phase-3-agent-integration.md` | Agent 集成:Graph 节点、边路由 |
|
||||
| `phase-4-streaming-interaction.md` | 流式交互:PTY 终端、WebSocket |
|
||||
| `phase-5-frontend-integration.md` | 前端集成:Vue 组件、xterm.js |
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先阅读本 README 了解整体架构
|
||||
2. 再按顺序阅读 phase 1 ~ phase 5
|
||||
3. 实施时严格按阶段推进
|
||||
|
||||
---
|
||||
|
||||
## 总体设计原则
|
||||
|
||||
1. **用户选择式交互** - 不是自动分流,用户显式选择 AI 提供商
|
||||
2. **安全分级执行** - 低风险直接执行,高风险沙盒隔离
|
||||
3. **流式终端体验** - 实时显示 AI 执行过程,支持用户交互
|
||||
4. **临时目录隔离** - 每个任务在独立临时目录执行,执行后清理
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览图
|
||||
|
||||
```
|
||||
Phase 1 ──────────────────────────────────────────────────────────────┐
|
||||
│ 基础设施 (Infrastructure) │
|
||||
│ - State 定义 │
|
||||
│ - Prompt 模板 │
|
||||
│ - 工具注册 │
|
||||
│ - Agent 注册 │
|
||||
│ │
|
||||
│ 核心文件: state.py, prompts.py, tools/__init__.py, builtins.py │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Phase 2 ──────────────────────────────────────────────────────────────┐
|
||||
│ 执行引擎 (Execution Engine) │
|
||||
│ - AI CLI Adapter (统一接口) │
|
||||
│ - Sandbox Executor │
|
||||
│ - Direct Executor │
|
||||
│ - Security Classifier │
|
||||
│ │
|
||||
│ 核心文件: ai_adapter.py, sandbox_executor.py, direct_executor.py, │
|
||||
│ security_classifier.py │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Phase 3 ──────────────────────────────────────────────────────────────┐
|
||||
│ Agent 集成 (Agent Integration) │
|
||||
│ - Graph 节点 │
|
||||
│ - 边路由 │
|
||||
│ - 任务模型 │
|
||||
│ │
|
||||
│ 核心文件: graph.py, schemas/task.py │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Phase 4 ──────────────────────────────────────────────────────────────┐
|
||||
│ 流式交互 (Streaming Interaction) │
|
||||
│ - PTY 终端 │
|
||||
│ - WebSocket 端点 │
|
||||
│ - 流式输出集成 │
|
||||
│ - 交互输入 │
|
||||
│ │
|
||||
│ 核心文件: terminal_engine.py, routers/terminal.py, stream_output.py │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Phase 5 ──────────────────────────────────────────────────────────────┐
|
||||
│ 前端集成 (Frontend Integration) │
|
||||
│ - 页面组件 │
|
||||
│ - 终端显示组件 │
|
||||
│ - WebSocket 服务 │
|
||||
│ - 路由配置 │
|
||||
│ │
|
||||
│ 核心文件: CodeCommander.vue, TerminalDisplay.vue, terminalWs.ts │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Vue 前端 │
|
||||
│ [用户选择: Claude/Gemini/Codex/OpenCode] + [输入需求] │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ WebSocket 流式输出
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FastAPI 后端 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 代码指挥官 (Code Commander Agent) │ │
|
||||
│ │ 1. 接收 AI 类型 + 用户需求 │ │
|
||||
│ │ 2. 安全分级判定 │ │
|
||||
│ │ 3. 路由到对应执行器 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┼──────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ 直接执行器 │ │ 沙盒执行器 │ │ 终端引擎 │ │
|
||||
│ │(低风险任务) │ │(高风险任务) │ │ PTY + 流式 │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ subprocess 调用
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CLI 进程 (claude/gemini/codex/opencode) │
|
||||
│ 在临时目录中执行 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo 项目借鉴映射
|
||||
|
||||
| Demo 项目 | 主要借鉴点 | 对应 Phase |
|
||||
|---------|-----------|-----------|
|
||||
| **golutra** | PTY 终端、多 CLI 适配、工作流隔离 | Phase 2, 4 |
|
||||
| **golutra CLI** | LocalSocket IPC、命令分发 | Phase 2 |
|
||||
| **golutra Shim** | 进程启动、信号处理 | Phase 2 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── 前端 UI + 路由
|
||||
│ │ │ └── PTY + WebSocket
|
||||
│ │ └── Graph 节点 + 边路由
|
||||
│ └── AI Adapter + Sandbox
|
||||
└── State + Prompt + 注册
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件变更追踪
|
||||
|
||||
| Phase | 新增文件 | 修改文件 |
|
||||
|-------|---------|---------|
|
||||
| Phase 1 | `tools/__init__.py` (改) | `state.py`, `prompts.py`, `registry/builtins.py` |
|
||||
| Phase 2 | `ai_adapter.py`, `sandbox_executor.py`, `direct_executor.py`, `security_classifier.py` | - |
|
||||
| Phase 3 | `schemas/task.py` (改) | `graph.py` |
|
||||
| Phase 4 | `terminal_engine.py`, `routers/terminal.py`, `stream_output.py`, `interactive_input.py` | - |
|
||||
| Phase 5 | `CodeCommander.vue`, `TerminalDisplay.vue`, `terminalWs.ts` | `router/index.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 注意事项 | 说明 |
|
||||
|---------|------|
|
||||
| 不要跳过 Phase | 每个阶段都是下一个的基础 |
|
||||
| AI CLI 前置检查 | 确保服务器上已安装对应 CLI |
|
||||
| 临时目录及时清理 | 防止磁盘空间泄漏 |
|
||||
| WebSocket 重连 | 前端实现自动重连机制 |
|
||||
215
development-doc/plan/code-update/checklist.md
Normal file
215
development-doc/plan/code-update/checklist.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 代码指挥官实施清单(可勾选执行版)
|
||||
|
||||
日期:2026-04-04
|
||||
状态:执行清单
|
||||
适用范围:基于 `phase-1` ~ `phase-5` 整理
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 完成前使用 `- [ ]`
|
||||
- 完成后改成 `- [x]`
|
||||
- Day 1-3 为后端基础设施
|
||||
- Day 4-5 为后端执行引擎
|
||||
- Day 6 为 Agent 集成
|
||||
- Day 7-8 为流式交互
|
||||
- Day 9-10 为前端集成
|
||||
|
||||
---
|
||||
|
||||
## Day 1:State + Prompt + 注册
|
||||
|
||||
Day 1 目标:完成代码指挥官 Agent 的基础架子
|
||||
|
||||
- [ ] 新增 `CODE_COMMANDER = "code_commander"` 到 `AgentRole` 枚举
|
||||
- [ ] 新增 `CodeCommanderState` TypedDict(包含 task_type, ai_provider, sandbox_mode 等)
|
||||
- [ ] 新增 `CODE_COMMANDER_SYSTEM_PROMPT` 系统提示
|
||||
- [ ] 新增 `SANDBOX_EXECUTION_PROMPT` 沙盒执行说明
|
||||
- [ ] 新增 `DIRECT_EXECUTION_PROMPT` 直接执行说明
|
||||
- [ ] 在 `SUB_COMMANDER_TOOLSETS` 中注册 `CODE_COMMANDER_TOOLSET`
|
||||
- [ ] 新增 `CodeCommanderManifest` 到 `AGENT_MANIFESTS`
|
||||
- [ ] 补 Phase 1 单元测试
|
||||
|
||||
**验收:确认 `AgentRole.CODE_COMMANDER` 存在且值正确**
|
||||
|
||||
---
|
||||
|
||||
## Day 2:AI CLI Adapter(统一接口)
|
||||
|
||||
Day 2 目标:实现适配不同 AI CLI 的统一接口
|
||||
|
||||
- [ ] 新增 `AICLIAdapter` 抽象基类
|
||||
- `cli_name` 属性
|
||||
- `requires_workspace` 属性
|
||||
- `build_command()` 方法
|
||||
- `parse_output()` 方法
|
||||
- `is_installed()` 方法
|
||||
- [ ] 新增 `ClaudeAdapter` 实现
|
||||
- [ ] 新增 `GeminiAdapter` 实现
|
||||
- [ ] 新增 `CodexAdapter` 实现
|
||||
- [ ] 新增 `OpenCodeAdapter` 实现
|
||||
- [ ] 新增 `CodeExecutionResult` 数据类
|
||||
- [ ] 补 Day 2 单元测试
|
||||
|
||||
**验收:`AICLIAdapter` 可以正确识别 4 种 CLI**
|
||||
|
||||
---
|
||||
|
||||
## Day 3:Security Classifier + Direct Executor
|
||||
|
||||
Day 3 目标:实现安全分级和直接执行器
|
||||
|
||||
- [ ] 新增 `RiskLevel` 枚举(LOW/HIGH)
|
||||
- [ ] 新增 `SecurityClassifier` 类
|
||||
- `HIGH_RISK_KEYWORDS` 列表
|
||||
- `LOW_RISK_KEYWORDS` 列表
|
||||
- `classify()` 方法实现
|
||||
- `_is_project_path()` 方法实现
|
||||
- [ ] 新增 `DirectExecutor` 类
|
||||
- `execute()` 方法(异步)
|
||||
- 超时控制
|
||||
- `is_installed()` 检查
|
||||
- [ ] 补 Day 3 单元测试
|
||||
|
||||
**验收:`SecurityClassifier` 能正确分类高低风险**
|
||||
|
||||
---
|
||||
|
||||
## Day 4:Sandbox Environment + Sandbox Executor
|
||||
|
||||
Day 4 目标:实现沙盒执行器
|
||||
|
||||
- [ ] 新增 `SandboxEnvironment` 类
|
||||
- `create()` 静态方法(创建临时目录)
|
||||
- `cleanup()` 方法
|
||||
- `workspace_path` 属性
|
||||
- `session_id` 属性
|
||||
- [ ] 新增 `SandboxExecutor` 类
|
||||
- `execute()` 方法(异步,yield 流式输出)
|
||||
- `cleanup_session()` 方法
|
||||
- `_list_created_files()` 方法
|
||||
- [ ] 实现超时控制
|
||||
- [ ] 补 Day 4 单元测试
|
||||
|
||||
**验收:`SandboxExecutor` 能创建、执行、清理沙盒**
|
||||
|
||||
---
|
||||
|
||||
## Day 5:执行引擎集成测试
|
||||
|
||||
Day 5 目标:确保执行引擎各组件协同工作
|
||||
|
||||
- [ ] 集成测试:`SecurityClassifier` + `DirectExecutor`
|
||||
- [ ] 集成测试:`SecurityClassifier` + `SandboxExecutor`
|
||||
- [ ] 集成测试:4 种 `AICLIAdapter` 的 `build_command()`
|
||||
- [ ] 端到端测试:低风险任务直接执行
|
||||
- [ ] 端到端测试:高风险任务沙盒执行
|
||||
- [ ] 确认沙盒目录创建和清理正常
|
||||
|
||||
**验收:所有执行器支持流式输出,且正确路由**
|
||||
|
||||
---
|
||||
|
||||
## Day 6:Graph 节点 + 边路由
|
||||
|
||||
Day 6 目标:将代码指挥官接入 LangGraph
|
||||
|
||||
- [ ] 新增 `code_commander_node` 函数
|
||||
- 获取用户需求和 AI 提供商
|
||||
- 调用 `SecurityClassifier`
|
||||
- 根据风险等级选择执行器
|
||||
- 返回执行结果
|
||||
- [ ] 在 `NODES` 字典中注册 `code_commander`
|
||||
- [ ] 新增 `_should_route_to_code_commander()` 路由函数
|
||||
- [ ] 在 `graph.py` 中添加条件边
|
||||
- [ ] 新增 `CodeTask`, `CodeExecutionResult` 模型到 `schemas/task.py`
|
||||
- [ ] 补 Day 6 单元测试
|
||||
|
||||
**验收:高风险任务路由到沙盒,低风险路由到直接执行**
|
||||
|
||||
---
|
||||
|
||||
## Day 7:PTY Terminal Engine
|
||||
|
||||
Day 7 目标:实现 PTY 终端管理
|
||||
|
||||
- [ ] 新增 `PTYSession` 数据类
|
||||
- [ ] 新增 `PTYManager` 类
|
||||
- `spawn()` 方法
|
||||
- `write()` 方法
|
||||
- `read()` 方法(异步生成器)
|
||||
- `resize()` 方法
|
||||
- `kill()` 方法
|
||||
- [ ] 实现 `asyncio.subprocess` 进程管理
|
||||
- [ ] 实现输出队列
|
||||
- [ ] 补 Day 7 单元测试
|
||||
|
||||
**验收:PTY 会话可以启动、读写、终止**
|
||||
|
||||
---
|
||||
|
||||
## Day 8:WebSocket + 流式输出
|
||||
|
||||
Day 8 目标:实现 WebSocket 端点和流式输出
|
||||
|
||||
- [ ] 新增 `ConnectionManager` 类
|
||||
- [ ] 新增 `/ws/terminal/{session_id}` WebSocket 端点
|
||||
- [ ] 实现连接管理(connect/disconnect)
|
||||
- [ ] 新增 `StreamOutput` 类
|
||||
- [ ] 实现 `stream_execution()` 方法
|
||||
- [ ] 新增 `InteractiveInputHandler` 类
|
||||
- [ ] 实现用户输入传递到 PTY
|
||||
- [ ] 补 Day 8 集成测试
|
||||
|
||||
**验收:WebSocket 连接正常,输出实时推送**
|
||||
|
||||
---
|
||||
|
||||
## Day 9:Vue 页面组件
|
||||
|
||||
Day 9 目标:前端代码指挥官主页面
|
||||
|
||||
- [ ] 新增 `CodeCommander.vue` 页面组件
|
||||
- AI 提供商选择器
|
||||
- 任务输入框
|
||||
- 执行按钮
|
||||
- 终端显示区域
|
||||
- 交互输入框
|
||||
- 下载/清理按钮
|
||||
- [ ] 补 Day 9 组件测试
|
||||
|
||||
**验收:用户可以选择 AI 提供商并输入任务**
|
||||
|
||||
---
|
||||
|
||||
## Day 10:TerminalDisplay + WebSocket 服务 + 路由
|
||||
|
||||
Day 10 目标:完成前端集成
|
||||
|
||||
- [ ] 新增 `TerminalDisplay.vue` 组件(xterm.js)
|
||||
- 终端渲染
|
||||
- ANSI 颜色支持
|
||||
- 用户输入处理
|
||||
- [ ] 新增 `terminalWs.ts` WebSocket 服务
|
||||
- 连接管理
|
||||
- 自动重连
|
||||
- 消息处理
|
||||
- [ ] 在 `router/index.ts` 新增 `/code-commander` 路由
|
||||
- [ ] 端到端测试:完整执行流程
|
||||
- [ ] 确认前端与后端 WebSocket 通信正常
|
||||
|
||||
**验收:用户可以在前端看到实时终端输出并交互**
|
||||
|
||||
---
|
||||
|
||||
## 最终验收
|
||||
|
||||
- [ ] 用户可以选择 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
||||
- [ ] 低风险任务(如贪食蛇 demo)直接执行
|
||||
- [ ] 高风险任务在临时目录沙盒执行
|
||||
- [ ] 终端输出实时流式显示
|
||||
- [ ] 用户可以中途输入交互(如 "y" 确认)
|
||||
- [ ] 临时目录执行后正确清理
|
||||
- [ ] 前端页面正常展示
|
||||
- [ ] 回归测试通过(现有功能不受影响)
|
||||
152
development-doc/plan/code-update/phase-1-infrastructure.md
Normal file
152
development-doc/plan/code-update/phase-1-infrastructure.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Phase 1:基础设施
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待实施
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
新增代码指挥官 Agent 的基础架子,包括:
|
||||
- State 定义(角色、状态)
|
||||
- Prompt 模板
|
||||
- 工具注册
|
||||
- Agent 注册
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细任务
|
||||
|
||||
### 2.1 State 定义
|
||||
|
||||
**文件**: `backend/app/agents/state.py`
|
||||
|
||||
```python
|
||||
# 新增 AgentRole
|
||||
class AgentRole(str, Enum):
|
||||
# ... 现有角色 ...
|
||||
CODE_COMMANDER = "code_commander"
|
||||
|
||||
# 新增 CodeCommanderState
|
||||
class CodeCommanderState(TypedDict):
|
||||
task_type: str # "demo" | "project" | "modification"
|
||||
ai_provider: str # "claude" | "gemini" | "codex" | "opencode"
|
||||
sandbox_mode: bool # True = 沙盒执行,False = 直接执行
|
||||
workspace_path: str | None # 临时工作目录
|
||||
execution_session_id: str | None # PTY 会话 ID
|
||||
```
|
||||
|
||||
### 2.2 Prompt 模板
|
||||
|
||||
**文件**: `backend/app/agents/prompts.py`
|
||||
|
||||
```python
|
||||
# 代码指挥官系统提示
|
||||
CODE_COMMANDER_SYSTEM_PROMPT = """你是一个代码指挥官,负责协调 AI 写代码助手。
|
||||
|
||||
你的职责:
|
||||
1. 接收用户选择的 AI 提供商(Claude/Gemini/Codex/OpenCode)
|
||||
2. 接收用户的写代码需求
|
||||
3. 进行安全分级判定
|
||||
4. 路由到合适的执行器
|
||||
|
||||
安全分级规则:
|
||||
- 低风险:demo、示例、贪食蛇游戏等独立项目
|
||||
- 高风险:修改现有项目、涉及 Jarvis 项目、路径操作等
|
||||
|
||||
执行模式:
|
||||
- 直接执行:低风险任务,直接运行
|
||||
- 沙盒执行:高风险任务,在临时目录隔离执行"""
|
||||
|
||||
# 沙盒执行说明
|
||||
SANDBOX_EXECUTION_PROMPT = """将在隔离的临时目录中执行任务。
|
||||
任务完成后,工作目录会被保留供下载。"""
|
||||
|
||||
# 直接执行说明
|
||||
DIRECT_EXECUTION_PROMPT = """将直接执行任务。
|
||||
如果需要交互,请等待用户输入。"""
|
||||
```
|
||||
|
||||
### 2.3 工具注册
|
||||
|
||||
**文件**: `backend/app/agents/tools/__init__.py`
|
||||
|
||||
```python
|
||||
# 新增工具集
|
||||
CODE_COMMANDER_TOOLSET = {
|
||||
"code_commander": [
|
||||
"execute_code_task",
|
||||
"get_execution_status",
|
||||
"send_interactive_input",
|
||||
"download_workspace",
|
||||
"cleanup_workspace",
|
||||
]
|
||||
}
|
||||
|
||||
# 在 SUB_COMMANDER_TOOLSETS 中添加
|
||||
SUB_COMMANDER_TOOLSETS: dict[str, list[str]] = {
|
||||
# ... 现有工具集 ...
|
||||
"code_commander": CODE_COMMANDER_TOOLSET["code_commander"],
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Agent 注册
|
||||
|
||||
**文件**: `backend/app/agents/registry/builtins.py`
|
||||
|
||||
```python
|
||||
# 新增 CodeCommanderManifest
|
||||
CodeCommanderManifest = AgentManifest(
|
||||
id="code_commander",
|
||||
name="代码指挥官",
|
||||
description="协调 AI 写代码助手的指挥官",
|
||||
system_prompt=CODE_COMMANDER_SYSTEM_PROMPT,
|
||||
role=AgentRole.CODE_COMMANDER,
|
||||
sub_commanders=[], # 代码指挥官没有子指挥官
|
||||
tools=["execute_code_task", "get_execution_status",
|
||||
"send_interactive_input", "download_workspace", "cleanup_workspace"],
|
||||
permission_class=PermissionClass.HIGH, # 需要较高权限
|
||||
side_effect_scope=SideEffectScope.WORKSPACE,
|
||||
supports_retry=True,
|
||||
idempotent=False,
|
||||
safe_for_parallel_use=False,
|
||||
requires_confirmation=True,
|
||||
)
|
||||
|
||||
# 注册到 AGENT_MANIFESTS
|
||||
AGENT_MANIFESTS: dict[str, AgentManifest] = {
|
||||
# ... 现有 agent ...
|
||||
"code_commander": CodeCommanderManifest,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `state.py` | 修改 | 新增 `CODE_COMMANDER` 角色和 `CodeCommanderState` |
|
||||
| `prompts.py` | 修改 | 新增三个 prompt 常量 |
|
||||
| `tools/__init__.py` | 修改 | 新增工具集注册 |
|
||||
| `registry/builtins.py` | 修改 | 新增 `CodeCommanderManifest` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] `AgentRole.CODE_COMMANDER` 存在且值正确
|
||||
- [ ] `CODE_COMMANDER_SYSTEM_PROMPT` 包含完整指令
|
||||
- [ ] 工具集已注册且可通过 `SUB_COMMANDER_TOOLSETS` 访问
|
||||
- [ ] `CodeCommanderManifest` 已注册且包含所有必要字段
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖关系
|
||||
|
||||
```
|
||||
本阶段 → Phase 2(执行引擎)
|
||||
→ Phase 3(Agent 集成)
|
||||
```
|
||||
|
||||
本阶段是后续所有阶段的基础。
|
||||
321
development-doc/plan/code-update/phase-2-execution-engine.md
Normal file
321
development-doc/plan/code-update/phase-2-execution-engine.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Phase 2:执行引擎
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待实施
|
||||
|
||||
依赖:Phase 1 完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现代码指挥官的核心执行能力:
|
||||
- AI CLI Adapter:统一接口适配不同 AI CLI
|
||||
- Sandbox Executor:沙盒环境执行
|
||||
- Direct Executor:直接执行低风险任务
|
||||
- Security Classifier:安全分级
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细任务
|
||||
|
||||
### 2.1 AI CLI Adapter
|
||||
|
||||
**新文件**: `backend/app/agents/tools/ai_adapter.py`
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class CodeExecutionResult:
|
||||
success: bool
|
||||
message: str
|
||||
files_created: list[str]
|
||||
output: str
|
||||
error: str | None
|
||||
|
||||
class AICLIAdapter(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def cli_name(self) -> str:
|
||||
"""CLI 命令名称,如 'claude', 'gemini'"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_workspace(self) -> bool:
|
||||
"""是否需要工作目录"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
"""构建 CLI 命令"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_output(self, output: str) -> CodeExecutionResult:
|
||||
"""解析 CLI 输出"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_installed(self) -> bool:
|
||||
"""检查 CLI 是否已安装"""
|
||||
pass
|
||||
|
||||
class ClaudeAdapter(AICLIAdapter):
|
||||
cli_name = "claude"
|
||||
requires_workspace = True
|
||||
|
||||
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
|
||||
return ["claude", "-p", prompt, "--dangerously-skip-permissions"]
|
||||
|
||||
# ... 其他方法实现
|
||||
|
||||
class GeminiAdapter(AICLIAdapter):
|
||||
cli_name = "gemini"
|
||||
requires_workspace = False
|
||||
# ...
|
||||
|
||||
class CodexAdapter(AICLIAdapter):
|
||||
cli_name = "codex"
|
||||
# ...
|
||||
|
||||
class OpenCodeAdapter(AICLIAdapter):
|
||||
cli_name = "opencode"
|
||||
# ...
|
||||
```
|
||||
|
||||
### 2.2 Security Classifier
|
||||
|
||||
**新文件**: `backend/app/agents/tools/security_classifier.py`
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class RiskLevel(Enum):
|
||||
LOW = "low" # 直接执行
|
||||
HIGH = "high" # 沙盒执行
|
||||
|
||||
class SecurityClassifier:
|
||||
HIGH_RISK_KEYWORDS = [
|
||||
"修改", "编辑", "删除", "移动",
|
||||
"Jarvis", "backend", "frontend",
|
||||
"git", "config", ".env",
|
||||
]
|
||||
|
||||
LOW_RISK_KEYWORDS = [
|
||||
"demo", "示例", "贪食蛇", "俄罗斯方块",
|
||||
"小游戏", "独立项目", "新项目",
|
||||
"创建一个", "写一个",
|
||||
]
|
||||
|
||||
def classify(self, task_description: str, target_path: str | None = None) -> RiskLevel:
|
||||
# 1. 检查高风险关键词
|
||||
if any(kw in task_description for kw in self.HIGH_RISK_KEYWORDS):
|
||||
return RiskLevel.HIGH
|
||||
|
||||
# 2. 检查目标路径
|
||||
if target_path and self._is_project_path(target_path):
|
||||
return RiskLevel.HIGH
|
||||
|
||||
# 3. 检查低风险关键词
|
||||
if any(kw in task_description for kw in self.LOW_RISK_KEYWORDS):
|
||||
return RiskLevel.LOW
|
||||
|
||||
# 4. 默认高风险
|
||||
return RiskLevel.HIGH
|
||||
|
||||
def _is_project_path(self, path: str) -> bool:
|
||||
# 检查是否指向 Jarvis 项目路径
|
||||
return "Jarvis" in path or "backend/app" in path
|
||||
```
|
||||
|
||||
### 2.3 Sandbox Executor
|
||||
|
||||
**新文件**: `backend/app/agents/tools/sandbox_executor.py`
|
||||
|
||||
```python
|
||||
import tempfile
|
||||
import shutil
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncGenerator
|
||||
|
||||
@dataclass
|
||||
class SandboxEnvironment:
|
||||
workspace_path: Path
|
||||
session_id: str
|
||||
|
||||
@staticmethod
|
||||
async def create() -> "SandboxEnvironment":
|
||||
"""创建新的沙盒环境"""
|
||||
temp_dir = tempfile.mkdtemp(prefix="jarvis_code_")
|
||||
session_id = Path(temp_dir).name
|
||||
return SandboxEnvironment(
|
||||
workspace_path=Path(temp_dir),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理沙盒环境"""
|
||||
if self.workspace_path.exists():
|
||||
shutil.rmtree(self.workspace_path)
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
success: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
files_created: list[str] = field(default_factory=list)
|
||||
|
||||
class SandboxExecutor:
|
||||
def __init__(self, adapter: AICLIAdapter, timeout: int = 300):
|
||||
self.adapter = adapter
|
||||
self.timeout = timeout
|
||||
self._sessions: dict[str, SandboxEnvironment] = {}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str | None = None
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""执行代码任务,yield 实时输出"""
|
||||
# 1. 创建或复用沙盒环境
|
||||
if session_id and session_id in self._sessions:
|
||||
env = self._sessions[session_id]
|
||||
else:
|
||||
env = await SandboxEnvironment.create()
|
||||
self._sessions[env.session_id] = env
|
||||
session_id = env.session_id
|
||||
|
||||
# 2. 构建命令
|
||||
cmd = self.adapter.build_command(prompt, env.workspace_path)
|
||||
|
||||
# 3. 异步执行,实时 yield 输出
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(env.workspace_path),
|
||||
)
|
||||
|
||||
# 4. 实时读取输出
|
||||
while True:
|
||||
line = await process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
yield line.decode()
|
||||
|
||||
# 5. 等待完成
|
||||
await process.wait()
|
||||
|
||||
# 6. 收集结果
|
||||
return ExecutionResult(
|
||||
success=process.returncode == 0,
|
||||
exit_code=process.returncode or 0,
|
||||
stdout=...,
|
||||
stderr=...,
|
||||
files_created=self._list_created_files(env.workspace_path),
|
||||
)
|
||||
|
||||
async def cleanup_session(self, session_id: str):
|
||||
"""清理指定会话"""
|
||||
if session_id in self._sessions:
|
||||
await self._sessions[session_id].cleanup()
|
||||
del self._sessions[session_id]
|
||||
```
|
||||
|
||||
### 2.4 Direct Executor
|
||||
|
||||
**新文件**: `backend/app/agents/tools/direct_executor.py`
|
||||
|
||||
```python
|
||||
class DirectExecutor:
|
||||
def __init__(self, adapter: AICLIAdapter, timeout: int = 60):
|
||||
self.adapter = adapter
|
||||
self.timeout = timeout
|
||||
|
||||
async def execute(self, prompt: str) -> ExecutionResult:
|
||||
"""直接执行,不需要沙盒"""
|
||||
if not self.adapter.is_installed():
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=f"{self.adapter.cli_name} is not installed",
|
||||
)
|
||||
|
||||
cmd = self.adapter.build_command(prompt, None)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return ExecutionResult(
|
||||
success=process.returncode == 0,
|
||||
exit_code=process.returncode or 0,
|
||||
stdout=stdout.decode(),
|
||||
stderr=stderr.decode(),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=f"Execution timed out after {self.timeout}s",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `ai_adapter.py` | 新增 | 抽象基类 + 4 个具体实现 |
|
||||
| `security_classifier.py` | 新增 | 安全分级器 |
|
||||
| `sandbox_executor.py` | 新增 | 沙盒执行器 |
|
||||
| `direct_executor.py` | 新增 | 直接执行器 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] `AICLIAdapter` 可以正确识别 4 种 CLI
|
||||
- [ ] `SecurityClassifier` 能正确分类高低风险
|
||||
- [ ] `SandboxExecutor` 能创建、执行、清理沙盒
|
||||
- [ ] `DirectExecutor` 能直接执行低风险任务
|
||||
- [ ] 所有执行器支持流式输出
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| AI CLI 未安装 | `is_installed()` 检查 + 友好提示 |
|
||||
| 执行超时 | `timeout` 参数控制 |
|
||||
| 沙盒清理遗漏 | 使用 `finally` 块确保清理 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 依赖关系
|
||||
|
||||
```
|
||||
Phase 1(基础设施)
|
||||
↓
|
||||
本阶段 → Phase 3(Agent 集成)
|
||||
→ Phase 4(流式交互)
|
||||
```
|
||||
162
development-doc/plan/code-update/phase-3-agent-integration.md
Normal file
162
development-doc/plan/code-update/phase-3-agent-integration.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Phase 3:Agent 集成
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待实施
|
||||
|
||||
依赖:Phase 1 + Phase 2 完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
将代码指挥官接入 LangGraph:
|
||||
- Graph 节点
|
||||
- 边路由
|
||||
- 任务模型
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细任务
|
||||
|
||||
### 2.1 Graph 节点
|
||||
|
||||
**文件**: `backend/app/agents/graph.py`
|
||||
|
||||
```python
|
||||
# 新增 code_commander_node
|
||||
async def code_commander_node(state: AgentState) -> AgentState:
|
||||
"""代码指挥官节点"""
|
||||
# 1. 获取用户需求和选择的 AI 提供商
|
||||
user_message = state.messages[-1].content
|
||||
ai_provider = state.get("ai_provider", "claude")
|
||||
|
||||
# 2. 安全分级
|
||||
classifier = SecurityClassifier()
|
||||
risk_level = classifier.classify(user_message)
|
||||
|
||||
# 3. 根据风险等级选择执行器
|
||||
adapter = get_adapter(ai_provider)
|
||||
if risk_level == RiskLevel.LOW:
|
||||
executor = DirectExecutor(adapter)
|
||||
result = await executor.execute(user_message)
|
||||
else:
|
||||
sandbox = await SandboxEnvironment.create()
|
||||
executor = SandboxExecutor(adapter)
|
||||
result = await executor.execute(user_message, sandbox.session_id)
|
||||
state["workspace_path"] = str(sandbox.workspace_path)
|
||||
state["execution_session_id"] = sandbox.session_id
|
||||
|
||||
# 4. 更新状态
|
||||
state.messages.append(AIMessage(content=str(result)))
|
||||
state["next_step"] = None # 任务完成
|
||||
|
||||
return state
|
||||
|
||||
# 节点注册到 NODES
|
||||
NODES: dict[str, NodeCallable] = {
|
||||
# ... 现有节点 ...
|
||||
"code_commander": code_commander_node,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 边路由
|
||||
|
||||
**文件**: `backend/app/agents/graph.py`
|
||||
|
||||
```python
|
||||
def _should_route_to_code_commander(state: AgentState) -> str:
|
||||
"""判断是否路由到代码指挥官"""
|
||||
if state.current_agent == "code_commander":
|
||||
return "code_commander"
|
||||
# ... 其他条件
|
||||
return END
|
||||
|
||||
# 边注册
|
||||
def _build_graph() -> CompiledGraph:
|
||||
# ... 现有边 ...
|
||||
|
||||
# 新增代码指挥官相关边
|
||||
graph.add_conditional_edges(
|
||||
"master",
|
||||
_should_route_to_code_commander,
|
||||
{
|
||||
"code_commander": "code_commander",
|
||||
END: END,
|
||||
}
|
||||
)
|
||||
|
||||
graph.add_edge("code_commander", END)
|
||||
|
||||
return graph.compile()
|
||||
```
|
||||
|
||||
### 2.3 任务模型
|
||||
|
||||
**文件**: `backend/app/agents/schemas/task.py`
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
class CodeProviderType(str, Enum):
|
||||
CLAUDE = "claude"
|
||||
GEMINI = "gemini"
|
||||
CODEX = "codex"
|
||||
OPENCODE = "opencode"
|
||||
|
||||
class RiskLevelType(str, Enum):
|
||||
LOW = "low"
|
||||
HIGH = "high"
|
||||
|
||||
class CodeTask(BaseModel):
|
||||
"""代码任务"""
|
||||
id: str = Field(default_factory=lambda: f"code_{uuid.uuid4().hex[:8]}")
|
||||
provider: CodeProviderType
|
||||
prompt: str
|
||||
risk_level: RiskLevelType
|
||||
sandbox_mode: bool
|
||||
workspace_path: str | None = None
|
||||
session_id: str | None = None
|
||||
status: Literal["pending", "running", "completed", "failed"] = "pending"
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
class CodeExecutionResult(BaseModel):
|
||||
"""代码执行结果"""
|
||||
task_id: str
|
||||
success: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
files_created: list[str] = Field(default_factory=list)
|
||||
workspace_path: str | None = None
|
||||
completed_at: datetime = Field(default_factory=datetime.now)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `graph.py` | 修改 | 新增 `code_commander_node` 和边路由 |
|
||||
| `schemas/task.py` | 修改 | 新增 `CodeTask`, `CodeExecutionResult` 等模型 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] `code_commander_node` 正确处理任务
|
||||
- [ ] `SecurityClassifier` 被正确调用
|
||||
- [ ] 高低风险任务路由到正确的执行器
|
||||
- [ ] `CodeTask` 和 `CodeExecutionResult` 模型正确
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖关系
|
||||
|
||||
```
|
||||
Phase 1 + Phase 2
|
||||
↓
|
||||
本阶段 → Phase 4(流式交互)
|
||||
→ Phase 5(前端集成)
|
||||
```
|
||||
@@ -0,0 +1,298 @@
|
||||
# Phase 4:流式交互
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待实施
|
||||
|
||||
依赖:Phase 3 完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现 PTY 终端 + WebSocket 流式输出:
|
||||
- PTY 终端管理
|
||||
- WebSocket 端点
|
||||
- 流式输出集成
|
||||
- 交互输入
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细任务
|
||||
|
||||
### 2.1 PTY Terminal Engine
|
||||
|
||||
**新文件**: `backend/app/agents/tools/terminal_engine.py`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncGenerator
|
||||
|
||||
@dataclass
|
||||
class PTYSession:
|
||||
session_id: str
|
||||
process: asyncio.subprocess.Process
|
||||
workspace_path: str
|
||||
|
||||
class PTYManager:
|
||||
def __init__(self):
|
||||
self._sessions: dict[str, PTYSession] = {}
|
||||
self._output_queues: dict[str, asyncio.Queue] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
cli: str,
|
||||
args: list[str],
|
||||
cwd: str,
|
||||
session_id: str | None = None
|
||||
) -> str:
|
||||
"""启动 PTY 会话"""
|
||||
if session_id is None:
|
||||
session_id = f"pty_{os.urandom(8).hex()}"
|
||||
|
||||
# 创建 PTY 进程
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*([cli] + args),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env={**os.environ, "TERM": "xterm-256color"},
|
||||
)
|
||||
|
||||
session = PTYSession(
|
||||
session_id=session_id,
|
||||
process=process,
|
||||
workspace_path=cwd,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
self._output_queues[session_id] = asyncio.Queue()
|
||||
|
||||
# 启动输出读取协程
|
||||
asyncio.create_task(self._read_output(session_id))
|
||||
|
||||
return session_id
|
||||
|
||||
async def _read_output(self, session_id: str):
|
||||
"""读取 PTY 输出并放入队列"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
queue = self._output_queues[session_id]
|
||||
|
||||
while True:
|
||||
line = await session.process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
await queue.put(line.decode())
|
||||
|
||||
# 同时推送给所有订阅者
|
||||
await self._broadcast(session_id, line.decode())
|
||||
|
||||
await queue.put(None) # 结束标记
|
||||
|
||||
async def write(self, session_id: str, data: str):
|
||||
"""写入 PTY(用户输入)"""
|
||||
session = self._sessions.get(session_id)
|
||||
if session and session.process.stdin:
|
||||
session.process.stdin.write(data)
|
||||
await session.process.stdin.drain()
|
||||
|
||||
async def read(self, session_id: str) -> AsyncGenerator[str, None]:
|
||||
"""读取 PTY 输出"""
|
||||
queue = self._output_queues.get(session_id)
|
||||
if not queue:
|
||||
return
|
||||
|
||||
while True:
|
||||
line = await queue.get()
|
||||
if line is None:
|
||||
break
|
||||
yield line
|
||||
|
||||
async def resize(self, session_id: str, rows: int, cols: int):
|
||||
"""调整终端大小"""
|
||||
# TODO: 实现 resize
|
||||
pass
|
||||
|
||||
async def kill(self, session_id: str):
|
||||
"""终止 PTY 会话"""
|
||||
if session_id in self._sessions:
|
||||
session = self._sessions[session_id]
|
||||
session.process.terminate()
|
||||
await session.process.wait()
|
||||
del self._sessions[session_id]
|
||||
del self._output_queues[session_id]
|
||||
|
||||
async def _broadcast(self, session_id: str, data: str):
|
||||
"""广播输出到 WebSocket"""
|
||||
# 实际推送由 router 层处理
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.2 WebSocket 端点
|
||||
|
||||
**新文件**: `backend/app/routers/terminal.py`
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import dict
|
||||
|
||||
router = APIRouter(prefix="/ws/terminal", tags=["terminal"])
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: dict[str, WebSocket] = {}
|
||||
|
||||
async def connect(self, session_id: str, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections[session_id] = websocket
|
||||
|
||||
def disconnect(self, session_id: str):
|
||||
if session_id in self.active_connections:
|
||||
del self.active_connections[session_id]
|
||||
|
||||
async def send(self, session_id: str, data: str):
|
||||
if session_id in self.active_connections:
|
||||
await self.active_connections[session_id].send_text(data)
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@router.websocket("/{session_id}")
|
||||
async def terminal_websocket(websocket: WebSocket, session_id: str):
|
||||
await manager.connect(session_id, websocket)
|
||||
|
||||
# 获取 PTY Manager 实例
|
||||
from app.agents.tools.terminal_engine import pty_manager
|
||||
|
||||
try:
|
||||
# 订阅该 session 的输出
|
||||
queue = pty_manager._output_queues.get(session_id)
|
||||
if queue:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
# 接收用户输入
|
||||
await pty_manager.write(session_id, data + "\n")
|
||||
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(session_id)
|
||||
```
|
||||
|
||||
### 2.3 流式输出集成
|
||||
|
||||
**新文件**: `backend/app/agents/tools/stream_output.py`
|
||||
|
||||
```python
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
type: str # "output" | "error" | "status" | "complete"
|
||||
session_id: str
|
||||
data: str
|
||||
timestamp: str
|
||||
|
||||
class StreamOutput:
|
||||
def __init__(self, session_id: str, websocket_sender):
|
||||
self.session_id = session_id
|
||||
self.websocket_sender = websocket_sender
|
||||
|
||||
async def push(self, event_type: str, data: str):
|
||||
"""推送事件到 WebSocket"""
|
||||
event = StreamEvent(
|
||||
type=event_type,
|
||||
session_id=self.session_id,
|
||||
data=data,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
await self.websocket_sender(self.session_id, json.dumps(event.__dict__))
|
||||
|
||||
async def stream_execution(
|
||||
self,
|
||||
executor,
|
||||
prompt: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""包装执行器,实现流式输出"""
|
||||
async for line in executor.execute(prompt):
|
||||
await self.push("output", line)
|
||||
yield line
|
||||
|
||||
await self.push("complete", "")
|
||||
```
|
||||
|
||||
### 2.4 交互输入
|
||||
|
||||
**新文件**: `backend/app/agents/tools/interactive_input.py`
|
||||
|
||||
```python
|
||||
class InteractiveInputHandler:
|
||||
def __init__(self, pty_manager: PTYManager):
|
||||
self.pty_manager = pty_manager
|
||||
self._pending_inputs: dict[str, asyncio.Event] = {}
|
||||
|
||||
async def wait_for_input(self, session_id: str, prompt: str) -> str:
|
||||
"""等待用户输入(如 "y" 确认)"""
|
||||
event = asyncio.Event()
|
||||
self._pending_inputs[session_id] = event
|
||||
|
||||
# 发送提示
|
||||
from app.routers.terminal import manager
|
||||
await manager.send(session_id, f"\n{prompt}\n")
|
||||
|
||||
# 等待输入完成
|
||||
await event.wait()
|
||||
del self._pending_inputs[session_id]
|
||||
|
||||
return self._input_cache.get(session_id, "")
|
||||
|
||||
async def send_input(self, session_id: str, data: str):
|
||||
"""用户发送输入"""
|
||||
self._input_cache[session_id] = data
|
||||
if session_id in self._pending_inputs:
|
||||
self._pending_inputs[session_id].set()
|
||||
|
||||
# 同时写入 PTY
|
||||
await self.pty_manager.write(session_id, data + "\n")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `terminal_engine.py` | 新增 | PTY 终端管理 |
|
||||
| `routers/terminal.py` | 新增 | WebSocket 端点 |
|
||||
| `stream_output.py` | 新增 | 流式输出封装 |
|
||||
| `interactive_input.py` | 新增 | 交互输入处理 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] PTY 会话可以启动、读写、终止
|
||||
- [ ] WebSocket 可以建立连接并收发消息
|
||||
- [ ] 执行输出实时推送到前端
|
||||
- [ ] 用户输入可以传递到 PTY
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖关系
|
||||
|
||||
```
|
||||
Phase 3(Agent 集成)
|
||||
↓
|
||||
本阶段 → Phase 5(前端集成)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 备注
|
||||
|
||||
PTY 实现参考了 golutra 的 `src-tauri/src/runtime/pty.rs`:
|
||||
- 使用 `portable-pty` 库
|
||||
- Windows 路径兼容处理
|
||||
- shim 机制用于信号处理
|
||||
364
development-doc/plan/code-update/phase-5-frontend-integration.md
Normal file
364
development-doc/plan/code-update/phase-5-frontend-integration.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Phase 5:前端集成
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待实施
|
||||
|
||||
依赖:Phase 4 完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
Vue 前端新增代码指挥官 UI:
|
||||
- 页面组件
|
||||
- 终端显示组件
|
||||
- WebSocket 服务
|
||||
- 路由配置
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细任务
|
||||
|
||||
### 2.1 页面组件
|
||||
|
||||
**新文件**: `frontend/src/pages/chat/CodeCommander.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="code-commander">
|
||||
<!-- AI 提供商选择器 -->
|
||||
<div class="provider-selector">
|
||||
<div class="label">选择 AI 助手</div>
|
||||
<div class="providers">
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:class="{ active: selectedProvider === p.id }"
|
||||
@click="selectedProvider = p.id"
|
||||
>
|
||||
<img :src="p.icon" :alt="p.name" />
|
||||
{{ p.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务输入 -->
|
||||
<div class="task-input">
|
||||
<textarea
|
||||
v-model="taskPrompt"
|
||||
placeholder="描述你想让 AI 帮你做什么..."
|
||||
rows="4"
|
||||
/>
|
||||
<button @click="executeTask" :disabled="isExecuting">
|
||||
{{ isExecuting ? '执行中...' : '开始执行' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 终端输出 -->
|
||||
<TerminalDisplay
|
||||
ref="terminalRef"
|
||||
:session-id="currentSessionId"
|
||||
@input="handleUserInput"
|
||||
/>
|
||||
|
||||
<!-- 交互输入框 -->
|
||||
<div v-if="isWaitingForInput" class="interactive-input">
|
||||
<span>{{ inputPrompt }}</span>
|
||||
<input v-model="userInput" @keyup.enter="sendUserInput" />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="actions">
|
||||
<button @click="downloadFiles" :disabled="!canDownload">
|
||||
下载文件
|
||||
</button>
|
||||
<button @click="cleanup" :disabled="!canCleanup">
|
||||
清理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import TerminalDisplay from '@/components/TerminalDisplay.vue'
|
||||
import { terminalWsService } from '@/services/terminalWs'
|
||||
|
||||
const providers = [
|
||||
{ id: 'claude', name: 'Claude', icon: '/icons/claude.png' },
|
||||
{ id: 'gemini', name: 'Gemini', icon: '/icons/gemini.png' },
|
||||
{ id: 'codex', name: 'Codex', icon: '/icons/codex.png' },
|
||||
{ id: 'opencode', name: 'OpenCode', icon: '/icons/opencode.png' },
|
||||
]
|
||||
|
||||
const selectedProvider = ref('claude')
|
||||
const taskPrompt = ref('')
|
||||
const isExecuting = ref(false)
|
||||
const currentSessionId = ref<string | null>(null)
|
||||
const isWaitingForInput = ref(false)
|
||||
const inputPrompt = ref('')
|
||||
const userInput = ref('')
|
||||
const terminalRef = ref<InstanceType<typeof TerminalDisplay> | null>(null)
|
||||
|
||||
const canDownload = computed(() => currentSessionId.value !== null)
|
||||
const canCleanup = computed(() => currentSessionId.value !== null)
|
||||
|
||||
async function executeTask() {
|
||||
if (!taskPrompt.value.trim()) return
|
||||
|
||||
isExecuting.value = true
|
||||
currentSessionId.value = await terminalWsService.connect(selectedProvider.value)
|
||||
|
||||
// 订阅消息
|
||||
terminalWsService.onMessage((msg) => {
|
||||
if (msg.type === 'output') {
|
||||
terminalRef.value?.write(msg.data)
|
||||
} else if (msg.type === 'waiting_input') {
|
||||
isWaitingForInput.value = true
|
||||
inputPrompt.value = msg.data
|
||||
} else if (msg.type === 'complete') {
|
||||
isExecuting.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 发送任务
|
||||
await terminalWsService.sendTask(currentSessionId.value, taskPrompt.value)
|
||||
}
|
||||
|
||||
function handleUserInput(data: string) {
|
||||
terminalWsService.sendInput(currentSessionId.value!, data)
|
||||
}
|
||||
|
||||
function sendUserInput() {
|
||||
terminalWsService.sendInput(currentSessionId.value!, userInput.value)
|
||||
userInput.value = ''
|
||||
isWaitingForInput.value = false
|
||||
}
|
||||
|
||||
async function downloadFiles() {
|
||||
// TODO: 调用下载 API
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (currentSessionId.value) {
|
||||
await terminalWsService.disconnect(currentSessionId.value)
|
||||
currentSessionId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2.2 终端显示组件
|
||||
|
||||
**新文件**: `frontend/src/components/TerminalDisplay.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="terminal-display" ref="containerRef">
|
||||
<div class="terminal-output" ref="outputRef"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
input: [data: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const outputRef = ref<HTMLElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
|
||||
onMounted(() => {
|
||||
terminal = new Terminal({
|
||||
theme: { background: '#1e1e1e' },
|
||||
cursorBlink: true,
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
terminal.open(outputRef.value!)
|
||||
fitAddon.fit()
|
||||
|
||||
// 用户输入
|
||||
terminal.onData((data) => {
|
||||
emit('input', data)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
terminal?.dispose()
|
||||
})
|
||||
|
||||
function write(data: string) {
|
||||
terminal?.write(data)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
terminal?.clear()
|
||||
}
|
||||
|
||||
defineExpose({ write, clear })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-display {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
padding: 12px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 2.3 WebSocket 服务
|
||||
|
||||
**新文件**: `frontend/src/services/terminalWs.ts`
|
||||
|
||||
```typescript
|
||||
type MessageHandler = (msg: StreamMessage) => void
|
||||
|
||||
interface StreamMessage {
|
||||
type: 'output' | 'error' | 'status' | 'waiting_input' | 'complete'
|
||||
session_id: string
|
||||
data: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
class TerminalWsService {
|
||||
private ws: WebSocket | null = null
|
||||
private sessionId: string | null = null
|
||||
private handlers: MessageHandler[] = []
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
|
||||
async connect(provider: string): Promise<string> {
|
||||
// 创建会话
|
||||
const response = await fetch('/api/code-commander/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider }),
|
||||
})
|
||||
const { session_id } = await response.json()
|
||||
|
||||
// 建立 WebSocket
|
||||
this.ws = new WebSocket(`ws://localhost:8000/ws/terminal/${session_id}`)
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg: StreamMessage = JSON.parse(event.data)
|
||||
this.handlers.forEach((h) => h(msg))
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.attemptReconnect()
|
||||
}
|
||||
|
||||
this.sessionId = session_id
|
||||
return session_id
|
||||
}
|
||||
|
||||
async sendTask(sessionId: string, prompt: string) {
|
||||
await fetch(`/api/code-commander/sessions/${sessionId}/task`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt }),
|
||||
})
|
||||
}
|
||||
|
||||
sendInput(sessionId: string, input: string) {
|
||||
this.ws?.send(JSON.stringify({ type: 'input', data: input }))
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler) {
|
||||
this.handlers.push(handler)
|
||||
}
|
||||
|
||||
removeHandler(handler: MessageHandler) {
|
||||
this.handlers = this.handlers.filter((h) => h !== handler)
|
||||
}
|
||||
|
||||
async disconnect(sessionId: string) {
|
||||
await fetch(`/api/code-commander/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.sessionId = null
|
||||
}
|
||||
|
||||
private async attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
return
|
||||
}
|
||||
this.reconnectAttempts++
|
||||
await new Promise((r) => setTimeout(r, 1000 * this.reconnectAttempts))
|
||||
// 重新连接
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalWsService = new TerminalWsService()
|
||||
```
|
||||
|
||||
### 2.4 路由配置
|
||||
|
||||
**文件**: `frontend/src/router/index.ts`
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/code-commander',
|
||||
name: 'CodeCommander',
|
||||
component: () => import('@/pages/chat/CodeCommander.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `CodeCommander.vue` | 新增 | 主页面组件 |
|
||||
| `TerminalDisplay.vue` | 新增 | 终端显示组件(xterm.js) |
|
||||
| `terminalWs.ts` | 新增 | WebSocket 服务 |
|
||||
| `router/index.ts` | 修改 | 新增路由 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] 用户可以选择 AI 提供商
|
||||
- [ ] 可以输入任务描述并执行
|
||||
- [ ] 终端实时显示 AI 输出
|
||||
- [ ] 用户可以输入交互(如 "y")
|
||||
- [ ] 可以下载和清理文件
|
||||
|
||||
---
|
||||
|
||||
## 5. 依赖
|
||||
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| xterm | ^5.x | 终端渲染 |
|
||||
| xterm-addon-fit | ^0.8.x | 自适应大小 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 依赖关系
|
||||
|
||||
```
|
||||
Phase 4(流式交互)
|
||||
↓
|
||||
本阶段(前端集成)→ 端到端测试
|
||||
```
|
||||
174
development-doc/plan/forum-update/README.md
Normal file
174
development-doc/plan/forum-update/README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Jarvis Forum 升级计划索引
|
||||
|
||||
本目录用于存放 Jarvis 论坛系统的分阶段升级规划文档。
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、实施顺序 |
|
||||
| `phase-f-0-current-state.md` | 当前现状、问题、目标架构、VCPToolBox 借鉴 |
|
||||
| `phase-f-1-data-model.md` | 数据模型升级 |
|
||||
| `phase-f-2-forum-api.md` | API 增强与安全 |
|
||||
| `phase-f-3-permissions.md` | 权限系统 |
|
||||
| `phase-f-4-ai-integration.md` | AI 集成 |
|
||||
| `checklist.md` | 执行清单 |
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先读 `phase-f-0-current-state.md`
|
||||
2. 再按顺序阅读 phase f-1 ~ f-4
|
||||
3. 实施时严格按阶段推进
|
||||
4. 参考 `checklist.md` 进行任务追踪
|
||||
|
||||
---
|
||||
|
||||
## 总体升级原则
|
||||
|
||||
1. **安全第一** - 输入验证、路径安全、并发控制
|
||||
2. **可扩展性** - 支持多板块、标签、分类
|
||||
3. **AI 增强** - AI 自动回复、摘要生成、智能分类
|
||||
4. **测试优先** - 所有升级都要配套测试
|
||||
5. **向后兼容** - 保持现有 API 兼容性
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览图
|
||||
|
||||
```
|
||||
F.0 ──────────────────────────────────────────────────────────────┐
|
||||
│ 现状与目标 │
|
||||
│ - 当前架构分析 │
|
||||
│ - 短板识别 │
|
||||
│ - VCPToolBox VCP论坛 借鉴 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
F.1 ──────────────────────────────────────────────────────────────┐
|
||||
│ 数据模型升级 │
|
||||
│ - 板块/分类系统 │
|
||||
│ - 标签系统 │
|
||||
│ - 帖子元数据扩展 │
|
||||
│ │
|
||||
│ 核心文件: models/forum.py │
|
||||
│ 工作量: 2 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
F.2 ──────────────────────────────────────────────────────────────┐
|
||||
│ API 增强与安全 │
|
||||
│ - 文件锁机制 │
|
||||
│ - 输入验证强化 │
|
||||
│ - 并发控制 │
|
||||
│ - API 端点扩展 │
|
||||
│ │
|
||||
│ 核心文件: routers/forum.py, services/forum_service.py │
|
||||
│ 依赖: F.1 │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
F.3 ──────────────────────────────────────────────────────────────┐
|
||||
│ 权限系统 │
|
||||
│ - 用户角色管理 │
|
||||
│ - 板块权限控制 │
|
||||
│ - 操作日志 │
|
||||
│ - 积分/奖励系统 │
|
||||
│ │
|
||||
│ 核心文件: models/user.py, services/forum_service.py │
|
||||
│ 依赖: F.2 │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
F.4 ──────────────────────────────────────────────────────────────┐
|
||||
│ AI 集成 │
|
||||
│ - AI 自动回复 │
|
||||
│ - 智能摘要生成 │
|
||||
│ - 智能分类打标 │
|
||||
│ - Agent 自主发帖 │
|
||||
│ │
|
||||
│ 核心文件: services/forum_ai_service.py │
|
||||
│ 依赖: F.3 │
|
||||
│ 工作量: 5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VCPToolBox VCP论坛 核心借鉴
|
||||
|
||||
| 借鉴点 | 实现位置 | 难度 |
|
||||
|--------|---------|------|
|
||||
| 文件锁机制(并发控制) | F.2 | 🟡 中 |
|
||||
| 严格输入验证 | F.2 | 🟢 低 |
|
||||
| 安全路径检查 | F.2 | 🟢 低 |
|
||||
| 板块/分类系统 | F.1 | 🟢 低 |
|
||||
| 标签系统 | F.1 | 🟢 低 |
|
||||
| 权限管理 | F.3 | 🟡 中 |
|
||||
| 积分系统 | F.3 | 🟡 中 |
|
||||
| AI 自动回复 | F.4 | 🟡 中 |
|
||||
| Agent 自主发帖 | F.4 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
F.0 → F.1 → F.2 → F.3 → F.4
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── AI集成
|
||||
│ │ │ └── 权限/积分
|
||||
│ │ └── API增强/安全
|
||||
│ └── 数据模型
|
||||
└── 现状与目标
|
||||
```
|
||||
|
||||
**注意:** F.1 是基础,后续阶段都依赖 F.1;F.4 需要 Agent 能力成熟。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更追踪
|
||||
|
||||
| Phase | 新增文件 | 修改文件 |
|
||||
|-------|---------|---------|
|
||||
| F.1 | - | `models/forum.py`, `schemas/forum.py` |
|
||||
| F.2 | `services/forum_service.py` | `routers/forum.py` |
|
||||
| F.3 | `models/user.py` (扩展角色), `services/permission_service.py` | `routers/forum.py`, `services/forum_service.py` |
|
||||
| F.4 | `services/forum_ai_service.py`, `services/summary_service.py` | `routers/forum.py`, `agents/` |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 1-5 的关系
|
||||
|
||||
| Agent Phase | Forum 协作内容 |
|
||||
|-------------|---------------|
|
||||
| Phase 1 | Task Schema 追踪 Forum 任务 |
|
||||
| Phase 2 | Forum 任务可分解给执行 Agent |
|
||||
| Phase 3 | Agent 可以自主在论坛发帖 |
|
||||
| Phase 4 | Forum 操作可视化 |
|
||||
| Phase 5 | 多 Agent 论坛协作 |
|
||||
| **Phase F** | **Forum 升级路径,与 Phase 1-5 协同** |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 注意事项 | 说明 |
|
||||
|---------|------|
|
||||
| F.1 是基础 | F.2-F.4 都依赖 F.1 的数据模型 |
|
||||
| 安全第一 | 严格输入验证,防止注入攻击 |
|
||||
| API 兼容性 | 保持现有 API 兼容 |
|
||||
| AI 集成 | F.4 需要 Agent 能力成熟后才能实现 |
|
||||
|
||||
---
|
||||
|
||||
## 总工作量
|
||||
|
||||
| Phase | 工作量 |
|
||||
|-------|--------|
|
||||
| F.1 | 2 天 |
|
||||
| F.2 | 3 天 |
|
||||
| F.3 | 3 天 |
|
||||
| F.4 | 5 天 |
|
||||
| **总计** | **13 天** |
|
||||
305
development-doc/plan/forum-update/checklist.md
Normal file
305
development-doc/plan/forum-update/checklist.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Forum 升级执行清单
|
||||
|
||||
本清单用于追踪 Forum 升级计划的执行进度。
|
||||
|
||||
---
|
||||
|
||||
## 总进度
|
||||
|
||||
| Phase | 名称 | 状态 | 工作量 |
|
||||
|-------|------|------|--------|
|
||||
| F.0 | 现状与目标 | ✅ 完成 | - |
|
||||
| F.1 | 数据模型升级 | ⬜ 待开始 | 2 天 |
|
||||
| F.2 | API 增强与安全 | ⬜ 待开始 | 3 天 |
|
||||
| F.3 | 权限系统 | ⬜ 待开始 | 3 天 |
|
||||
| F.4 | AI 集成 | ⬜ 待开始 | 5 天 |
|
||||
| **总计** | | | **13 天** |
|
||||
|
||||
---
|
||||
|
||||
## Phase F.1:数据模型升级
|
||||
|
||||
### 目标
|
||||
升级 Forum 数据模型,支持多板块、标签系统、帖子元数据扩展。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 数据库迁移
|
||||
- [ ] 创建数据库迁移脚本
|
||||
- 新增 `forum_boards` 表
|
||||
- 新增 `forum_tags` 表
|
||||
- 新增 `forum_post_tags` 表
|
||||
- 新增 `forum_likes` 表
|
||||
- 新增 `forum_stats` 表
|
||||
- 扩展 `forum_posts` 表
|
||||
- 扩展 `forum_replies` 表
|
||||
|
||||
#### 模型实现
|
||||
- [ ] 扩展 `models/forum.py`
|
||||
- [ ] 创建 `ForumBoard` 模型
|
||||
- [ ] 创建 `ForumTag` 模型
|
||||
- [ ] 创建 `ForumPostTag` 模型
|
||||
- [ ] 创建 `ForumLike` 模型
|
||||
- [ ] 创建 `ForumStats` 模型
|
||||
- [ ] 扩展 `ForumPost` 字段
|
||||
- [ ] 扩展 `ForumReply` 字段
|
||||
|
||||
#### Schema 实现
|
||||
- [ ] 扩展 `schemas/forum.py`
|
||||
- [ ] 创建 `ForumBoardCreate/Out` Schema
|
||||
- [ ] 扩展 `ForumPostCreate/Out` Schema
|
||||
- [ ] 扩展 `ForumReplyCreate/Out` Schema
|
||||
- [ ] 创建 `ForumTagOut` Schema
|
||||
|
||||
#### 测试
|
||||
- [ ] 编写单元测试
|
||||
- [ ] 测试 ForumBoard CRUD
|
||||
- [ ] 测试 ForumTag CRUD
|
||||
- [ ] 测试 ForumLike 功能
|
||||
- [ ] 测试 ForumStats 更新
|
||||
|
||||
### 产出文件
|
||||
- `models/forum.py`
|
||||
- `schemas/forum.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 迁移脚本可正常运行
|
||||
- [ ] 所有新模型可正常创建
|
||||
- [ ] Schema 验证正常工作
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase F.2:API 增强与安全
|
||||
|
||||
### 目标
|
||||
增强 Forum API 功能,实现文件锁、输入验证、限流、缓存。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 基础服务
|
||||
- [ ] 创建 `services/forum_service.py`
|
||||
- [ ] 实现 `ForumLockManager` 类
|
||||
- [ ] 实现 `ForumService` 类
|
||||
- [ ] 实现板块 CRUD 方法
|
||||
- [ ] 实现帖子 CRUD 方法
|
||||
- [ ] 实现回复 CRUD 方法
|
||||
- [ ] 实现点赞方法
|
||||
|
||||
#### 安全机制
|
||||
- [ ] 实现输入验证
|
||||
- [ ] 实现 `sanitize_input` 函数
|
||||
- [ ] 实现 `validate_post_data` 函数
|
||||
- [ ] 实现配置常量
|
||||
- [ ] 实现限流器
|
||||
- [ ] 实现 `RateLimiter` 类
|
||||
- [ ] 集成发帖限流
|
||||
- [ ] 集成回复限流
|
||||
- [ ] 实现缓存
|
||||
- [ ] 实现 `ForumCache` 类
|
||||
- [ ] 实现帖子缓存
|
||||
- [ ] 实现标签缓存
|
||||
|
||||
#### API 端点
|
||||
- [ ] 扩展 `routers/forum.py`
|
||||
- [ ] GET `/boards` - 列出板块
|
||||
- [ ] POST `/boards` - 创建板块
|
||||
- [ ] GET `/posts` - 分页获取帖子
|
||||
- [ ] POST `/posts` - 创建帖子
|
||||
- [ ] PATCH `/posts/{id}/pin` - 置顶
|
||||
- [ ] PATCH `/posts/{id}/lock` - 锁定
|
||||
- [ ] GET `/tags` - 列出/搜索标签
|
||||
- [ ] POST `/posts/{id}/tags` - 添加标签
|
||||
- [ ] POST `/like` - 切换点赞
|
||||
|
||||
#### 测试
|
||||
- [ ] 编写单元测试
|
||||
- [ ] 测试 ForumLockManager
|
||||
- [ ] 测试输入验证
|
||||
- [ ] 测试限流器
|
||||
- [ ] 测试 API 端点
|
||||
|
||||
### 产出文件
|
||||
- `services/forum_service.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 并发锁正常工作
|
||||
- [ ] 输入验证可过滤危险字符
|
||||
- [ ] 限流正常工作
|
||||
- [ ] 缓存提升读取速度
|
||||
- [ ] 所有 API 端点正常
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase F.3:权限系统
|
||||
|
||||
### 目标
|
||||
实现用户角色管理、板块权限控制、操作日志、积分系统。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 用户扩展
|
||||
- [ ] 扩展 `models/user.py`
|
||||
- [ ] 添加 `role` 字段
|
||||
- [ ] 添加 `forum_score` 字段
|
||||
- [ ] 添加论坛统计字段
|
||||
- [ ] 添加禁言相关字段
|
||||
- [ ] 添加 `moderated_boards` 字段
|
||||
|
||||
#### 权限服务
|
||||
- [ ] 创建 `services/permission_service.py`
|
||||
- [ ] 实现 `UserRole` 枚举
|
||||
- [ ] 实现 `Permission` 枚举
|
||||
- [ ] 实现 `ROLE_PERMISSIONS` 映射
|
||||
- [ ] 实现 `PermissionService` 类
|
||||
- [ ] 实现 `has_permission` 方法
|
||||
- [ ] 实现 `can_edit_post` 方法
|
||||
- [ ] 实现 `can_delete_post` 方法
|
||||
- [ ] 实现 `ban_user` 方法
|
||||
- [ ] 实现 `unban_user` 方法
|
||||
|
||||
#### 日志系统
|
||||
- [ ] 扩展 `models/forum.py`
|
||||
- [ ] 创建 `ForumLog` 模型
|
||||
- [ ] 集成日志记录
|
||||
- [ ] 记录帖子操作
|
||||
- [ ] 记录回复操作
|
||||
- [ ] 记录用户操作
|
||||
|
||||
#### 积分服务
|
||||
- [ ] 创建 `services/score_service.py`
|
||||
- [ ] 实现 `SCORE_RULES` 配置
|
||||
- [ ] 实现 `ScoreService` 类
|
||||
- [ ] 实现 `add_score` 方法
|
||||
- [ ] 实现 `get_leaderboard` 方法
|
||||
- [ ] 实现积分自动增减
|
||||
|
||||
#### 管理 API
|
||||
- [ ] 扩展 `routers/forum.py`
|
||||
- [ ] POST `/admin/ban/{user_id}` - 禁言
|
||||
- [ ] POST `/admin/unban/{user_id}` - 解除禁言
|
||||
- [ ] GET `/admin/logs` - 查看日志
|
||||
- [ ] GET `/leaderboard` - 积分排行榜
|
||||
|
||||
#### 测试
|
||||
- [ ] 编写单元测试
|
||||
- [ ] 测试权限检查
|
||||
- [ ] 测试禁言功能
|
||||
- [ ] 测试积分计算
|
||||
- [ ] 测试排行榜
|
||||
|
||||
### 产出文件
|
||||
- `models/user.py`
|
||||
- `models/forum.py` (ForumLog)
|
||||
- `services/permission_service.py`
|
||||
- `services/score_service.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 角色权限正确控制
|
||||
- [ ] 禁言功能正常
|
||||
- [ ] 操作日志正确记录
|
||||
- [ ] 积分正确增减
|
||||
- [ ] 排行榜正确排序
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase F.4:AI 集成
|
||||
|
||||
### 目标
|
||||
实现 AI 自动回复、摘要生成、智能打标、Agent 自主发帖。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### AI 服务
|
||||
- [ ] 创建 `services/forum_ai_service.py`
|
||||
- [ ] 实现 `ForumAIConfig` 配置
|
||||
- [ ] 实现 `ForumAIService` 类
|
||||
- [ ] 实现 `generate_auto_reply` 方法
|
||||
- [ ] 实现 `_should_auto_reply` 判断
|
||||
- [ ] 实现 `generate_summary` 方法
|
||||
- [ ] 实现 `suggest_tags` 方法
|
||||
- [ ] 实现 `classify_category` 方法
|
||||
|
||||
#### 摘要服务
|
||||
- [ ] 创建 `services/summary_service.py`
|
||||
- [ ] 实现 `SummaryService` 类
|
||||
- [ ] 实现 `get_post_summary` 方法
|
||||
- [ ] 实现 `get_thread_summary` 方法
|
||||
- [ ] 实现缓存失效
|
||||
|
||||
#### Agent 工具
|
||||
- [ ] 创建 `agents/tools/forum_tools.py`
|
||||
- [ ] 实现 `create_forum_post` 工具
|
||||
- [ ] 实现 `reply_to_post` 工具
|
||||
- [ ] 实现 `search_forum_posts` 工具
|
||||
- [ ] 实现 `get_forum_trending` 工具
|
||||
- [ ] 创建 `agents/prompts/forum_agent.py`
|
||||
- [ ] 编写 Forum Agent 提示词
|
||||
- [ ] 配置工具列表
|
||||
|
||||
#### 定时任务
|
||||
- [ ] 创建 `tasks/forum_auto_reply.py`
|
||||
- [ ] 实现 `auto_reply_task` 函数
|
||||
- [ ] 实现 `setup_forum_scheduler` 函数
|
||||
|
||||
#### API 端点
|
||||
- [ ] 扩展 `routers/forum.py`
|
||||
- [ ] POST `/posts/{id}/generate-summary` - 生成摘要
|
||||
- [ ] POST `/posts/suggest-tags` - 推荐标签
|
||||
- [ ] POST `/posts/classify` - 分类帖子
|
||||
- [ ] GET `/posts/{id}/ai-status` - AI 状态
|
||||
|
||||
#### 测试
|
||||
- [ ] 编写单元测试
|
||||
- [ ] 测试自动回复生成
|
||||
- [ ] 测试摘要生成
|
||||
- [ ] 测试智能打标
|
||||
- [ ] 测试 API 端点
|
||||
|
||||
### 产出文件
|
||||
- `services/forum_ai_service.py`
|
||||
- `services/summary_service.py`
|
||||
- `agents/tools/forum_tools.py`
|
||||
- `agents/prompts/forum_agent.py`
|
||||
- `tasks/forum_auto_reply.py`
|
||||
|
||||
### 验收
|
||||
- [ ] AI 服务正常调用
|
||||
- [ ] 自动回复正常生成
|
||||
- [ ] 摘要生成正常
|
||||
- [ ] 智能打标推荐
|
||||
- [ ] 智能分类推荐
|
||||
- [ ] Agent 工具可用
|
||||
- [ ] 定时任务正常执行
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] 所有 Phase F.1-F.4 任务完成
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] API 文档更新完成
|
||||
- [ ] 用户文档更新完成
|
||||
- [ ] 部署验证通过
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| F.4 AI 调用成本高 | 费用增加 | 设置每日调用上限 |
|
||||
| 并发锁死锁 | 服务不可用 | 超时机制 + 定期清理 |
|
||||
| 迁移数据丢失 | 用户流失 | 备份 + 分阶段迁移 |
|
||||
| Agent 刷屏 | 用户体验下降 | 严格限流 + 人工审核 |
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 日期 | Phase | 变更内容 |
|
||||
|------|-------|----------|
|
||||
| 2026-04-04 | F.0 | 创建文档 |
|
||||
205
development-doc/plan/forum-update/phase-f-0-current-state.md
Normal file
205
development-doc/plan/forum-update/phase-f-0-current-state.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Phase F.0:Forum 现状与目标
|
||||
|
||||
日期:2026-04-04
|
||||
状态:已完成
|
||||
借鉴来源:VCPToolBox VCP论坛 模块
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
本文件用于统一背景认知,明确:
|
||||
|
||||
- Jarvis 当前 Forum 架构处于什么水平
|
||||
- 主要短板是什么
|
||||
- 为什么要升级
|
||||
- 升级后的目标形态是什么
|
||||
- VCPToolBox VCP论坛 给我们什么启发
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前 Jarvis Forum 架构
|
||||
|
||||
### 2.1 核心流程
|
||||
|
||||
```
|
||||
用户发帖/回复 → ForumRouter → ForumPost/ForumReply 模型 → SQLite
|
||||
```
|
||||
|
||||
### 2.2 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/app/routers/forum.py` | 论坛 API 路由 |
|
||||
| `backend/app/models/forum.py` | ForumPost/ForumReply 数据模型 |
|
||||
| `backend/app/schemas/forum.py` | Pydantic 请求/响应模型 |
|
||||
|
||||
### 2.3 当前数据模型
|
||||
|
||||
```python
|
||||
class ForumPost(BaseModel):
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
category: str # instruction, discussion, question
|
||||
is_executed: bool
|
||||
execution_result: Optional[str]
|
||||
reply_count: int
|
||||
|
||||
class ForumReply(BaseModel):
|
||||
post_id: str
|
||||
user_id: Optional[str]
|
||||
agent_id: Optional[str]
|
||||
content: str
|
||||
is_ai_reply: bool
|
||||
```
|
||||
|
||||
### 2.4 当前 API 端点
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/api/forum/posts` | 列出帖子 |
|
||||
| POST | `/api/forum/posts` | 创建帖子 |
|
||||
| GET | `/api/forum/posts/{post_id}` | 获取帖子 |
|
||||
| DELETE | `/api/forum/posts/{post_id}` | 删除帖子 |
|
||||
| GET | `/api/forum/posts/{post_id}/replies` | 列出回复 |
|
||||
| POST | `/api/forum/posts/{post_id}/replies` | 创建回复 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前能力矩阵
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 基本 CRUD | ✅ | 帖子和回复的增删查 |
|
||||
| 分类标签 | ✅ | instruction/discussion/question |
|
||||
| AI 回复标记 | ✅ | `is_ai_reply` 字段 |
|
||||
| 回复计数 | ✅ | `reply_count` 字段 |
|
||||
| 执行状态 | ✅ | `is_executed`/`execution_result` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前短板
|
||||
|
||||
| 短板 | 严重程度 | 影响 |
|
||||
|------|----------|------|
|
||||
| 无板块系统 | 🔴 高 | 所有帖子混在一起 |
|
||||
| 无标签系统 | 🟡 中 | 无法细粒度分类 |
|
||||
| 无输入验证 | 🔴 高 | 安全风险 |
|
||||
| 无并发控制 | 🟡 中 | 多用户操作可能冲突 |
|
||||
| 无权限管理 | 🟡 中 | 无法控制谁可以做什么 |
|
||||
| 无积分系统 | 🟡 中 | 无法激励参与 |
|
||||
| 无 AI 自动回复 | 🟡 中 | 需要人工回复 |
|
||||
| 无摘要生成 | 🟢 低 | 长帖子难以快速浏览 |
|
||||
| 无 Agent 自主发帖 | 🟢 低 | Agent 无法主动参与 |
|
||||
|
||||
---
|
||||
|
||||
## 5. VCPToolBox VCP论坛 核心借鉴
|
||||
|
||||
### 5.1 VCP论坛架构
|
||||
|
||||
VCPToolBox 的论坛是 Agent 社区交流平台,支持:
|
||||
- 多 Agent 和用户共同参与
|
||||
- 超栈追踪和统一 FileAPI
|
||||
- 文件上传和多媒体支持
|
||||
- 积分系统和任务版集成
|
||||
|
||||
### 5.2 核心安全机制
|
||||
|
||||
```javascript
|
||||
// forumApi.js 中的安全配置
|
||||
const FORUM_CONFIG = {
|
||||
MAX_CONTENT_LENGTH: 50000, // 单条内容最大长度 50KB
|
||||
MAX_FILE_SIZE: 1024 * 1024 * 2, // 单个帖子文件最大 2MB
|
||||
MAX_MAID_LENGTH: 50, // 用户名最大长度
|
||||
MAX_TITLE_LENGTH: 100, // 标题最大长度
|
||||
MAX_FLOORS_PER_POST: 500, // 单帖最大楼层数
|
||||
UID_PATTERN: /^[a-zA-Z0-9_-]+$/, // UID 允许的字符
|
||||
LOCK_TIMEOUT: 10000, // 文件锁超时 10秒
|
||||
MAX_CONCURRENT_WRITES: 5, // 最大并发写入数
|
||||
};
|
||||
```
|
||||
|
||||
### 5.3 核心安全函数
|
||||
|
||||
```javascript
|
||||
// 文件锁管理器 - 并发控制
|
||||
class FileLockManager {
|
||||
async acquireLock(filePath, timeout)
|
||||
releaseLock(filePath)
|
||||
}
|
||||
|
||||
// 安全路径检查 - 防止路径遍历
|
||||
function isPathSafe(targetPath, rootPath)
|
||||
|
||||
// 输入清理 - 防止注入
|
||||
function sanitizeInput(input, maxLength)
|
||||
|
||||
// 安全的文件名解析
|
||||
function parsePostFilename(filename)
|
||||
```
|
||||
|
||||
### 5.4 关键设计理念
|
||||
|
||||
1. **安全第一** - 严格的输入验证、路径安全、并发控制
|
||||
2. **可扩展性** - 支持多板块、多楼层、文件附件
|
||||
3. **Agent 集成** - Agent 可以自主发帖、回帖
|
||||
4. **积分激励** - Agent 通过发帖获得积分
|
||||
|
||||
---
|
||||
|
||||
## 6. 目标架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User/Agent │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Forum Router Layer │
|
||||
│ - 输入验证 (sanitize) │
|
||||
│ - 权限检查 (permission) │
|
||||
│ - 限流控制 (rate_limit) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Forum Service Layer │
|
||||
│ - FileLock (并发控制) │
|
||||
│ - PermissionService (权限) │
|
||||
│ - AIService (AI 回复/摘要) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Data Layer │
|
||||
│ - ForumPost (板块/标签) │
|
||||
│ - ForumReply (层级) │
|
||||
│ - UserRole (权限) │
|
||||
│ - ForumStats (积分) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 借鉴点映射
|
||||
|
||||
| VCPToolBox 借鉴点 | Jarvis 实现位置 | 优先级 |
|
||||
|-------------------|---------------|--------|
|
||||
| 文件锁机制 | `services/forum_service.py` | 🟢 高 |
|
||||
| 输入验证强化 | `routers/forum.py` | 🟢 高 |
|
||||
| 安全路径检查 | `services/forum_service.py` | 🟢 高 |
|
||||
| 板块/分类系统 | `models/forum.py` | 🟢 高 |
|
||||
| 标签系统 | `models/forum.py` | 🟡 中 |
|
||||
| 权限管理 | `services/permission_service.py` | 🟡 中 |
|
||||
| 积分系统 | `models/user.py` | 🟡 中 |
|
||||
| AI 自动回复 | `services/forum_ai_service.py` | 🟡 中 |
|
||||
| 摘要生成 | `services/summary_service.py` | 🟢 低 |
|
||||
| Agent 自主发帖 | `agents/` | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 本阶段产出要求
|
||||
|
||||
- [x] 团队对 Jarvis 当前 Forum 问题和目标方向达成一致
|
||||
- [x] VCPToolBox VCP论坛 借鉴点已映射到具体 Phase
|
||||
- [x] 后续 phase 文档能够在这个认知基础上展开
|
||||
361
development-doc/plan/forum-update/phase-f-1-data-model.md
Normal file
361
development-doc/plan/forum-update/phase-f-1-data-model.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Phase F.1:数据模型升级
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:F.0(已完成)
|
||||
前置:models/forum.py
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
升级 Jarvis Forum 的数据模型,支持:
|
||||
- 多板块系统(指令/讨论/任务等)
|
||||
- 标签系统(细粒度分类)
|
||||
- 帖子元数据扩展(阅读量、点赞、收藏等)
|
||||
- 层级回复结构
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标模型
|
||||
|
||||
### 2.1 ForumBoard(板块)
|
||||
|
||||
```python
|
||||
class ForumBoard(BaseModel):
|
||||
__tablename__ = "forum_boards"
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str # 板块名称,如 "指令广场"
|
||||
slug: str # URL 友好名称,如 "instruction"
|
||||
description: Optional[str]
|
||||
icon: Optional[str] # emoji 或图标名
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
min_role: str = "user" # 查看权限:guest/user/moderator/admin
|
||||
|
||||
parent_id: Optional[str] # 父板块,支持层级
|
||||
color: Optional[str] # 板块颜色
|
||||
```
|
||||
|
||||
### 2.2 ForumPost(帖子)扩展
|
||||
|
||||
```python
|
||||
class ForumPost(BaseModel):
|
||||
__tablename__ = "forum_posts"
|
||||
|
||||
# === 现有字段 ===
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
reply_count: int = 0
|
||||
|
||||
# === 新增字段 ===
|
||||
board_id: Optional[str] = None # 所属板块
|
||||
parent_id: Optional[str] = None # 父帖子(支持回复嵌套)
|
||||
|
||||
# 分类与标签
|
||||
category: Optional[str] # instruction/discussion/question/praise/bug
|
||||
tags: Optional[str] = "" # JSON 数组 ["python", "api"]
|
||||
|
||||
# 状态与权限
|
||||
is_pinned: bool = False # 置顶
|
||||
is_locked: bool = False # 锁定(禁止回复)
|
||||
is_deleted: bool = False # 软删除
|
||||
is_executed: bool = False # 指令已执行
|
||||
execution_result: Optional[str]
|
||||
|
||||
# 统计与互动
|
||||
view_count: int = 0
|
||||
like_count: int = 0
|
||||
favorite_count: int = 0
|
||||
share_count: int = 0
|
||||
|
||||
# 时间戳
|
||||
last_reply_at: Optional[datetime] = None # 最后回复时间
|
||||
last_activity_at: Optional[datetime] = None
|
||||
|
||||
# === 关系 ===
|
||||
board = relationship("ForumBoard", back_populates="posts")
|
||||
replies = relationship("ForumReply", back_populates="post", cascade="all, delete-orphan")
|
||||
post_tags = relationship("ForumPostTag", back_populates="post", cascade="all, delete-orphan")
|
||||
likes = relationship("ForumLike", back_populates="post", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
### 2.3 ForumReply(回复)扩展
|
||||
|
||||
```python
|
||||
class ForumReply(BaseModel):
|
||||
__tablename__ = "forum_replies"
|
||||
|
||||
# === 现有字段 ===
|
||||
post_id: str
|
||||
user_id: Optional[str]
|
||||
agent_id: Optional[str]
|
||||
content: str
|
||||
is_ai_reply: bool = False
|
||||
|
||||
# === 新增字段 ===
|
||||
parent_id: Optional[str] = None # 父回复(支持嵌套)
|
||||
floor: int = 1 # 楼层号
|
||||
is_pinned: bool = False # 置顶
|
||||
is_best: bool = False # 最佳回复
|
||||
is_deleted: bool = False # 软删除
|
||||
like_count: int = 0
|
||||
edited_at: Optional[datetime] = None # 编辑时间
|
||||
|
||||
# === 关系 ===
|
||||
post = relationship("ForumPost", back_populates="replies")
|
||||
parent = relationship("ForumReply", remote_side=[id], backref="children")
|
||||
likes = relationship("ForumLike", back_populates="reply", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
### 2.4 ForumTag(标签)
|
||||
|
||||
```python
|
||||
class ForumTag(BaseModel):
|
||||
__tablename__ = "forum_tags"
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str # 标签名,如 "python"
|
||||
slug: str # URL 友好名
|
||||
color: str = "#666666" # 标签颜色
|
||||
post_count: int = 0 # 帖子数量(缓存)
|
||||
is_official: bool = False # 官方标签
|
||||
board_id: Optional[str] = None # 限定板块
|
||||
|
||||
posts = relationship("ForumPostTag", back_populates="tag")
|
||||
|
||||
|
||||
class ForumPostTag(BaseModel):
|
||||
"""帖子-标签关联"""
|
||||
__tablename__ = "forum_post_tags"
|
||||
|
||||
post_id: str
|
||||
tag_id: str
|
||||
|
||||
post = relationship("ForumPost", back_populates="post_tags")
|
||||
tag = relationship("ForumTag", back_populates="posts")
|
||||
```
|
||||
|
||||
### 2.5 ForumLike(点赞)
|
||||
|
||||
```python
|
||||
class ForumLike(BaseModel):
|
||||
__tablename__ = "forum_likes"
|
||||
|
||||
user_id: str
|
||||
post_id: Optional[str] = None
|
||||
reply_id: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
post = relationship("ForumPost", back_populates="likes")
|
||||
reply = relationship("ForumReply", back_populates="likes")
|
||||
```
|
||||
|
||||
### 2.6 ForumStats(用户统计)
|
||||
|
||||
```python
|
||||
class ForumStats(BaseModel):
|
||||
"""用户论坛统计"""
|
||||
__tablename__ = "forum_stats"
|
||||
|
||||
user_id: str = Field(primary_key=True)
|
||||
post_count: int = 0
|
||||
reply_count: int = 0
|
||||
like_received: int = 0 # 收到的赞
|
||||
best_reply_count: int = 0 # 最佳回复数
|
||||
score: int = 0 # 积分
|
||||
|
||||
last_post_at: Optional[datetime] = None
|
||||
last_reply_at: Optional[datetime] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库迁移
|
||||
|
||||
### 3.1 新增表
|
||||
|
||||
```sql
|
||||
-- 板块表
|
||||
CREATE TABLE forum_boards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
min_role TEXT DEFAULT 'user',
|
||||
parent_id TEXT,
|
||||
color TEXT
|
||||
);
|
||||
|
||||
-- 帖子-标签关联表
|
||||
CREATE TABLE forum_post_tags (
|
||||
post_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
|
||||
-- 标签表
|
||||
CREATE TABLE forum_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
color TEXT DEFAULT '#666666',
|
||||
post_count INTEGER DEFAULT 0,
|
||||
is_official BOOLEAN DEFAULT FALSE,
|
||||
board_id TEXT
|
||||
);
|
||||
|
||||
-- 点赞表
|
||||
CREATE TABLE forum_likes (
|
||||
user_id TEXT NOT NULL,
|
||||
post_id TEXT,
|
||||
reply_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, post_id, reply_id)
|
||||
);
|
||||
|
||||
-- 用户论坛统计表
|
||||
CREATE TABLE forum_stats (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
post_count INTEGER DEFAULT 0,
|
||||
reply_count INTEGER DEFAULT 0,
|
||||
like_received INTEGER DEFAULT 0,
|
||||
best_reply_count INTEGER DEFAULT 0,
|
||||
score INTEGER DEFAULT 0,
|
||||
last_post_at TIMESTAMP,
|
||||
last_reply_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- 扩展 forum_posts 表
|
||||
ALTER TABLE forum_posts ADD COLUMN board_id TEXT;
|
||||
ALTER TABLE forum_posts ADD COLUMN parent_id TEXT;
|
||||
ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT '';
|
||||
ALTER TABLE forum_posts ADD COLUMN is_pinned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_posts ADD COLUMN is_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_posts ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_posts ADD COLUMN view_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE forum_posts ADD COLUMN like_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE forum_posts ADD COLUMN favorite_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE forum_posts ADD COLUMN share_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE forum_posts ADD COLUMN last_reply_at TIMESTAMP;
|
||||
ALTER TABLE forum_posts ADD COLUMN last_activity_at TIMESTAMP;
|
||||
|
||||
-- 扩展 forum_replies 表
|
||||
ALTER TABLE forum_replies ADD COLUMN parent_id TEXT;
|
||||
ALTER TABLE forum_replies ADD COLUMN floor INTEGER DEFAULT 1;
|
||||
ALTER TABLE forum_replies ADD COLUMN is_pinned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_replies ADD COLUMN is_best BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_replies ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE forum_replies ADD COLUMN like_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE forum_replies ADD COLUMN edited_at TIMESTAMP;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema 扩展
|
||||
|
||||
### 4.1 ForumBoardSchema
|
||||
|
||||
```python
|
||||
class ForumBoardCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100)
|
||||
slug: str = Field(..., max_length=50, pattern=r"^[a-z0-9-]+$")
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
min_role: str = "user"
|
||||
parent_id: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class ForumBoardOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str]
|
||||
icon: Optional[str]
|
||||
sort_order: int
|
||||
post_count: int = 0 # 动态计算
|
||||
|
||||
|
||||
class ForumPostCreate(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
content: str = Field(..., max_length=50000)
|
||||
board_id: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
|
||||
|
||||
class ForumPostOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
board_id: Optional[str]
|
||||
board_name: Optional[str] = None
|
||||
category: Optional[str]
|
||||
tags: List[str] = []
|
||||
reply_count: int
|
||||
view_count: int
|
||||
like_count: int
|
||||
is_pinned: bool
|
||||
is_locked: bool
|
||||
created_at: datetime
|
||||
last_reply_at: Optional[datetime]
|
||||
user_liked: bool = False # 当前用户是否点赞
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 创建数据库迁移脚本 | 🟢 高 |
|
||||
| 2 | 扩展 models/forum.py | 🟢 高 |
|
||||
| 3 | 创建 ForumBoard 模型 | 🟢 高 |
|
||||
| 4 | 创建 ForumTag/ForumPostTag 模型 | 🟡 中 |
|
||||
| 5 | 创建 ForumLike 模型 | 🟡 中 |
|
||||
| 6 | 创建 ForumStats 模型 | 🟡 中 |
|
||||
| 7 | 扩展 schemas/forum.py | 🟢 高 |
|
||||
| 8 | 编写单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `models/forum.py` | 新增 ForumBoard, ForumTag, ForumPostTag, ForumLike, ForumStats;扩展 ForumPost, ForumReply |
|
||||
| `schemas/forum.py` | 新增 ForumBoardCreate/Out;扩展 ForumPostCreate/Out |
|
||||
| `database.py` | 注册新模型 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| 数据库迁移脚本 | 0.5 天 |
|
||||
| 模型扩展 | 0.5 天 |
|
||||
| Schema 扩展 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **2 天** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- [ ] ForumBoard 模型可创建/查询板块
|
||||
- [ ] ForumPost 可关联板块和标签
|
||||
- [ ] ForumTag 可创建/查询标签
|
||||
- [ ] ForumLike 支持点赞功能
|
||||
- [ ] ForumStats 正确统计用户数据
|
||||
- [ ] 所有新模型有对应的 Pydantic Schema
|
||||
- [ ] 迁移脚本可回滚
|
||||
- [ ] 单元测试覆盖新增功能
|
||||
488
development-doc/plan/forum-update/phase-f-2-forum-api.md
Normal file
488
development-doc/plan/forum-update/phase-f-2-forum-api.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# Phase F.2:API 增强与安全
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:F.1(待完成)
|
||||
前置:routers/forum.py
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
增强 Jarvis Forum API 的功能和安全:
|
||||
|
||||
- 引入文件锁机制(并发控制)
|
||||
- 强化输入验证
|
||||
- 扩展 API 端点
|
||||
- 实现缓存机制
|
||||
- 添加限流保护
|
||||
|
||||
---
|
||||
|
||||
## 2. Forum Service 架构
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
backend/app/services/
|
||||
└── forum_service.py # 新增
|
||||
```
|
||||
|
||||
### 2.2 核心服务类
|
||||
|
||||
```python
|
||||
class ForumLockManager:
|
||||
"""文件锁管理器 - 防止并发写入冲突"""
|
||||
|
||||
def __init__(self, max_concurrent: int = 5, timeout: int = 10):
|
||||
self.locks: Dict[str, LockInfo] = {}
|
||||
self.max_concurrent = max_concurrent
|
||||
self.timeout = timeout
|
||||
|
||||
async def acquire_lock(self, resource_id: str) -> bool:
|
||||
"""获取锁"""
|
||||
|
||||
def release_lock(self, resource_id: str) -> None:
|
||||
"""释放锁"""
|
||||
|
||||
def cleanup_stale_locks(self) -> None:
|
||||
"""清理过期锁"""
|
||||
|
||||
|
||||
class ForumService:
|
||||
"""论坛服务主类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
lock_manager: ForumLockManager,
|
||||
):
|
||||
self.db = db
|
||||
self.lock = lock_manager
|
||||
|
||||
# === 板块操作 ===
|
||||
async def create_board(self, data: ForumBoardCreate) -> ForumBoard:
|
||||
"""创建板块"""
|
||||
|
||||
async def list_boards(self) -> List[ForumBoard]:
|
||||
"""列出板块"""
|
||||
|
||||
# === 帖子操作 ===
|
||||
async def create_post(
|
||||
self,
|
||||
user_id: str,
|
||||
data: ForumPostCreate,
|
||||
) -> ForumPost:
|
||||
"""创建帖子(带锁)"""
|
||||
async with self.lock.acquire_lock(f"post:{user_id}"):
|
||||
# 创建帖子逻辑
|
||||
|
||||
async def get_posts(
|
||||
self,
|
||||
board_id: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> PaginatedResult[ForumPostOut]:
|
||||
"""分页获取帖子"""
|
||||
|
||||
async def get_post(self, post_id: str, user_id: Optional[str] = None) -> ForumPostOut:
|
||||
"""获取帖子详情(增加浏览量)"""
|
||||
|
||||
async def update_post(
|
||||
self,
|
||||
post_id: str,
|
||||
user_id: str,
|
||||
data: ForumPostUpdate,
|
||||
) -> ForumPost:
|
||||
"""更新帖子"""
|
||||
|
||||
async def delete_post(self, post_id: str, user_id: str) -> None:
|
||||
"""删除帖子(软删除)"""
|
||||
|
||||
async def pin_post(self, post_id: str, is_pinned: bool) -> None:
|
||||
"""置顶/取消置顶"""
|
||||
|
||||
async def lock_post(self, post_id: str, is_locked: bool) -> None:
|
||||
"""锁定/解锁帖子"""
|
||||
|
||||
# === 回复操作 ===
|
||||
async def create_reply(
|
||||
self,
|
||||
post_id: str,
|
||||
user_id: Optional[str],
|
||||
data: ForumReplyCreate,
|
||||
) -> ForumReply:
|
||||
"""创建回复(带锁)"""
|
||||
|
||||
async def get_replies(
|
||||
self,
|
||||
post_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> PaginatedResult[ForumReplyOut]:
|
||||
"""获取回复列表"""
|
||||
|
||||
async def update_reply(
|
||||
self,
|
||||
reply_id: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
) -> ForumReply:
|
||||
"""更新回复"""
|
||||
|
||||
async def delete_reply(self, reply_id: str, user_id: str) -> None:
|
||||
"""删除回复"""
|
||||
|
||||
# === 点赞操作 ===
|
||||
async def toggle_like(
|
||||
self,
|
||||
user_id: str,
|
||||
post_id: Optional[str] = None,
|
||||
reply_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""切换点赞状态"""
|
||||
|
||||
# === 标签操作 ===
|
||||
async def create_tag(self, name: str, color: str = "#666666") -> ForumTag:
|
||||
"""创建标签"""
|
||||
|
||||
async def search_tags(self, query: str) -> List[ForumTag]:
|
||||
"""搜索标签"""
|
||||
|
||||
async def add_tags_to_post(
|
||||
self,
|
||||
post_id: str,
|
||||
user_id: str,
|
||||
tags: List[str],
|
||||
) -> None:
|
||||
"""为帖子添加标签"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安全机制
|
||||
|
||||
### 3.1 输入验证
|
||||
|
||||
```python
|
||||
# Forum 安全配置
|
||||
FORUM_CONFIG = {
|
||||
"MAX_CONTENT_LENGTH": 50000, # 内容最大 50KB
|
||||
"MAX_TITLE_LENGTH": 200, # 标题最大 200 字符
|
||||
"MAX_TAGS_PER_POST": 10, # 每帖最多标签
|
||||
"MAX_REPLIES_PER_POST": 500, # 每帖最多回复
|
||||
"MAX_POSTS_PER_USER_PER_DAY": 50, # 每人每天最多发帖
|
||||
"MAX_REPLIES_PER_USER_PER_DAY": 200, # 每人每天最多回复
|
||||
}
|
||||
|
||||
|
||||
def sanitize_input(text: str, max_length: int) -> str:
|
||||
"""清理用户输入"""
|
||||
if not text or not isinstance(text, str):
|
||||
return ""
|
||||
# 移除控制字符
|
||||
text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
|
||||
# 限制长度
|
||||
return text[:max_length]
|
||||
|
||||
|
||||
def validate_post_data(data: ForumPostCreate) -> ForumPostCreate:
|
||||
"""验证帖子数据"""
|
||||
# 标题验证
|
||||
data.title = sanitize_input(data.title, FORUM_CONFIG["MAX_TITLE_LENGTH"])
|
||||
if len(data.title.strip()) < 3:
|
||||
raise ValueError("标题至少 3 个字符")
|
||||
|
||||
# 内容验证
|
||||
data.content = sanitize_input(data.content, FORUM_CONFIG["MAX_CONTENT_LENGTH"])
|
||||
if len(data.content.strip()) < 10:
|
||||
raise ValueError("内容至少 10 个字符")
|
||||
|
||||
# 标签验证
|
||||
if len(data.tags) > FORUM_CONFIG["MAX_TAGS_PER_POST"]:
|
||||
raise ValueError(f"最多 {FORUM_CONFIG['MAX_TAGS_PER_POST']} 个标签")
|
||||
data.tags = [sanitize_input(t, 50) for t in data.tags]
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
### 3.2 并发控制
|
||||
|
||||
```python
|
||||
class RateLimiter:
|
||||
"""简单的限流器"""
|
||||
|
||||
def __init__(self):
|
||||
self.user_requests: Dict[str, List[datetime]] = defaultdict(list)
|
||||
|
||||
def check_rate_limit(
|
||||
self,
|
||||
user_id: str,
|
||||
action: str,
|
||||
max_requests: int,
|
||||
window_seconds: int = 86400,
|
||||
) -> bool:
|
||||
"""检查是否超过限流"""
|
||||
now = datetime.utcnow()
|
||||
cutoff = now - timedelta(seconds=window_seconds)
|
||||
|
||||
# 清理过期记录
|
||||
self.user_requests[user_id] = [
|
||||
t for t in self.user_requests[user_id] if t > cutoff
|
||||
]
|
||||
|
||||
if len(self.user_requests[user_id]) >= max_requests:
|
||||
return False
|
||||
|
||||
self.user_requests[user_id].append(now)
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.3 缓存策略
|
||||
|
||||
```python
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
|
||||
class ForumCache:
|
||||
"""论坛缓存"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache: Dict[str, CacheEntry] = {}
|
||||
self.max_size = 1000
|
||||
self.default_ttl = 300 # 5 分钟
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""获取缓存"""
|
||||
if key in self.cache:
|
||||
entry = self.cache[key]
|
||||
if entry.is_expired():
|
||||
del self.cache[key]
|
||||
else:
|
||||
return entry.value
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = None) -> None:
|
||||
"""设置缓存"""
|
||||
if len(self.cache) >= self.max_size:
|
||||
# LRU 清理
|
||||
oldest = min(self.cache.items(), key=lambda x: x[1].created_at)
|
||||
del self.cache[oldest[0]]
|
||||
|
||||
self.cache[key] = CacheEntry(
|
||||
value=value,
|
||||
ttl=ttl or self.default_ttl,
|
||||
)
|
||||
|
||||
async def invalidate(self, pattern: str) -> None:
|
||||
"""清除匹配模式的缓存"""
|
||||
for key in list(self.cache.keys()):
|
||||
if pattern.format(key):
|
||||
del self.cache[key]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 端点扩展
|
||||
|
||||
### 4.1 板块 API
|
||||
|
||||
```python
|
||||
@router.get("/boards", response_model=list[ForumBoardOut])
|
||||
async def list_boards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出所有板块"""
|
||||
service = ForumService(db, lock_manager)
|
||||
return await service.list_boards()
|
||||
|
||||
|
||||
@router.post("/boards", response_model=ForumBoardOut, status_code=201)
|
||||
async def create_board(
|
||||
data: ForumBoardCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建板块(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
service = ForumService(db, lock_manager)
|
||||
return await service.create_board(data)
|
||||
```
|
||||
|
||||
### 4.2 帖子 API 扩展
|
||||
|
||||
```python
|
||||
@router.get("/posts", response_model=PaginatedResponse[ForumPostOut])
|
||||
async def list_posts(
|
||||
board_id: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[str] = None, # comma-separated
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分页获取帖子"""
|
||||
service = ForumService(db, lock_manager)
|
||||
tag_list = tags.split(",") if tags else None
|
||||
return await service.get_posts(
|
||||
board_id=board_id,
|
||||
category=category,
|
||||
tags=tag_list,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts", response_model=ForumPostOut, status_code=201)
|
||||
async def create_post(
|
||||
data: ForumPostCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建帖子"""
|
||||
# 限流检查
|
||||
if not rate_limiter.check_rate_limit(
|
||||
current_user.id, "post", FORUM_CONFIG["MAX_POSTS_PER_USER_PER_DAY"]
|
||||
):
|
||||
raise HTTPException(status_code=429, detail="今日发帖次数已达上限")
|
||||
|
||||
# 验证数据
|
||||
data = validate_post_data(data)
|
||||
|
||||
service = ForumService(db, lock_manager)
|
||||
return await service.create_post(current_user.id, data)
|
||||
|
||||
|
||||
@router.patch("/posts/{post_id}/pin")
|
||||
async def pin_post(
|
||||
post_id: str,
|
||||
is_pinned: bool,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""置顶/取消置顶(仅版主+)"""
|
||||
service = ForumService(db, lock_manager)
|
||||
await service.pin_post(post_id, is_pinned)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/posts/{post_id}/lock")
|
||||
async def lock_post(
|
||||
post_id: str,
|
||||
is_locked: bool,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""锁定/解锁帖子"""
|
||||
service = ForumService(db, lock_manager)
|
||||
await service.lock_post(post_id, is_locked)
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### 4.3 标签 API
|
||||
|
||||
```python
|
||||
@router.get("/tags", response_model=list[ForumTagOut])
|
||||
async def list_tags(
|
||||
query: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""搜索/列出标签"""
|
||||
service = ForumService(db, lock_manager)
|
||||
if query:
|
||||
return await service.search_tags(query)
|
||||
return await service.list_tags()
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/tags")
|
||||
async def add_tags(
|
||||
post_id: str,
|
||||
tags: List[str],
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""为帖子添加标签"""
|
||||
service = ForumService(db, lock_manager)
|
||||
await service.add_tags_to_post(post_id, current_user.id, tags)
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### 4.4 点赞 API
|
||||
|
||||
```python
|
||||
@router.post("/like")
|
||||
async def toggle_like(
|
||||
post_id: Optional[str] = None,
|
||||
reply_id: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换点赞状态"""
|
||||
if not post_id and not reply_id:
|
||||
raise HTTPException(status_code=400, detail="需要指定 post_id 或 reply_id")
|
||||
|
||||
service = ForumService(db, lock_manager)
|
||||
liked = await service.toggle_like(
|
||||
user_id=current_user.id,
|
||||
post_id=post_id,
|
||||
reply_id=reply_id,
|
||||
)
|
||||
return {"liked": liked}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 创建 ForumLockManager | 🟢 高 |
|
||||
| 2 | 创建 ForumService | 🟢 高 |
|
||||
| 3 | 实现输入验证函数 | 🟢 高 |
|
||||
| 4 | 实现限流器 | 🟡 中 |
|
||||
| 5 | 实现缓存 | 🟡 中 |
|
||||
| 6 | 扩展 Router 端点 | 🟢 高 |
|
||||
| 7 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `services/forum_service.py` | 新增 |
|
||||
| `services/__init__.py` | 添加 export |
|
||||
| `routers/forum.py` | 扩展端点,集成服务 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| ForumLockManager | 0.5 天 |
|
||||
| ForumService 核心 | 1 天 |
|
||||
| 输入验证/限流/缓存 | 0.5 天 |
|
||||
| API 端点扩展 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **3 天** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- [ ] ForumLockManager 可正确管理并发锁
|
||||
- [ ] 输入验证可过滤危险字符
|
||||
- [ ] 限流器可防止滥用
|
||||
- [ ] 缓存可提升热门帖子读取速度
|
||||
- [ ] 所有新增 API 端点正常工作
|
||||
- [ ] 现有 API 保持向后兼容
|
||||
- [ ] 单元测试覆盖核心逻辑
|
||||
540
development-doc/plan/forum-update/phase-f-3-permissions.md
Normal file
540
development-doc/plan/forum-update/phase-f-3-permissions.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# Phase F.3:权限系统
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:F.2(待完成)
|
||||
前置:models/user.py
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现 Jarvis Forum 的权限系统:
|
||||
|
||||
- 用户角色管理(user/moderator/admin)
|
||||
- 板块权限控制
|
||||
- 操作日志记录
|
||||
- 积分/奖励系统
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户角色系统
|
||||
|
||||
### 2.1 角色定义
|
||||
|
||||
```python
|
||||
class UserRole(str, Enum):
|
||||
"""用户角色枚举"""
|
||||
GUEST = "guest" # 访客 - 只能浏览
|
||||
USER = "user" # 普通用户 - 可发帖/回复
|
||||
MODERATOR = "moderator" # 版主 - 可管理板块
|
||||
ADMIN = "admin" # 管理员 - 全权限
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
"""权限枚举"""
|
||||
# 帖子权限
|
||||
POST_CREATE = "post:create"
|
||||
POST_EDIT_OWN = "post:edit:own"
|
||||
POST_EDIT_ANY = "post:edit:any"
|
||||
POST_DELETE_OWN = "post:delete:own"
|
||||
POST_DELETE_ANY = "post:delete:any"
|
||||
POST_PIN = "post:pin"
|
||||
POST_LOCK = "post:lock"
|
||||
POST_VIEW = "post:view"
|
||||
|
||||
# 回复权限
|
||||
REPLY_CREATE = "reply:create"
|
||||
REPLY_EDIT_OWN = "reply:edit:own"
|
||||
REPLY_DELETE_OWN = "reply:delete:own"
|
||||
REPLY_DELETE_ANY = "reply:delete:any"
|
||||
REPLY_PIN = "reply:pin"
|
||||
REPLY_BEST = "reply:best"
|
||||
|
||||
# 板块权限
|
||||
BOARD_CREATE = "board:create"
|
||||
BOARD_EDIT = "board:edit"
|
||||
BOARD_DELETE = "board:delete"
|
||||
|
||||
# 标签权限
|
||||
TAG_CREATE = "tag:create"
|
||||
TAG_MANAGE = "tag:manage"
|
||||
|
||||
# 用户权限
|
||||
USER_BAN = "user:ban"
|
||||
USER_ROLE = "user:role"
|
||||
|
||||
# 积分权限
|
||||
SCORE_VIEW = "score:view"
|
||||
SCORE_MANAGE = "score:manage"
|
||||
```
|
||||
|
||||
### 2.2 角色权限映射
|
||||
|
||||
```python
|
||||
ROLE_PERMISSIONS: Dict[UserRole, Set[Permission]] = {
|
||||
UserRole.GUEST: {
|
||||
Permission.POST_VIEW,
|
||||
},
|
||||
UserRole.USER: {
|
||||
Permission.POST_VIEW,
|
||||
Permission.POST_CREATE,
|
||||
Permission.POST_EDIT_OWN,
|
||||
Permission.POST_DELETE_OWN,
|
||||
Permission.REPLY_CREATE,
|
||||
Permission.REPLY_EDIT_OWN,
|
||||
Permission.REPLY_DELETE_OWN,
|
||||
Permission.SCORE_VIEW,
|
||||
},
|
||||
UserRole.MODERATOR: {
|
||||
# 用户权限
|
||||
Permission.POST_VIEW,
|
||||
Permission.POST_CREATE,
|
||||
Permission.POST_EDIT_OWN,
|
||||
Permission.POST_DELETE_OWN,
|
||||
Permission.REPLY_CREATE,
|
||||
Permission.REPLY_EDIT_OWN,
|
||||
Permission.REPLY_DELETE_OWN,
|
||||
# 版主权限
|
||||
Permission.POST_PIN,
|
||||
Permission.POST_LOCK,
|
||||
Permission.POST_DELETE_ANY,
|
||||
Permission.REPLY_PIN,
|
||||
Permission.REPLY_BEST,
|
||||
Permission.TAG_MANAGE,
|
||||
Permission.SCORE_VIEW,
|
||||
},
|
||||
UserRole.ADMIN: set(Permission), # 所有权限
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. User 模型扩展
|
||||
|
||||
### 3.1 新增字段
|
||||
|
||||
```python
|
||||
# models/user.py 扩展
|
||||
class User(BaseModel):
|
||||
__tablename__ = "users"
|
||||
|
||||
# === 现有字段 ===
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
hashed_password: str
|
||||
avatar: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
# === Forum 相关扩展 ===
|
||||
role: str = "user" # guest/user/moderator/admin
|
||||
forum_score: int = 0 # 论坛积分
|
||||
|
||||
# 论坛统计
|
||||
post_count: int = 0
|
||||
reply_count: int = 0
|
||||
like_received: int = 0
|
||||
best_reply_count: int = 0
|
||||
|
||||
# 状态
|
||||
is_forum_banned: bool = False # 论坛禁言
|
||||
forum_ban_until: Optional[datetime] = None # 禁言截止时间
|
||||
|
||||
# 板块版主关系
|
||||
moderated_boards: List[str] = [] # 管理的板块 ID 列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Permission Service
|
||||
|
||||
### 4.1 服务实现
|
||||
|
||||
```python
|
||||
class PermissionService:
|
||||
"""权限服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
def has_permission(self, user: User, permission: Permission) -> bool:
|
||||
"""检查用户是否拥有某权限"""
|
||||
if user.is_forum_banned:
|
||||
return False
|
||||
if user.forum_ban_until and user.forum_ban_until > datetime.utcnow():
|
||||
return False
|
||||
|
||||
role = UserRole(user.role)
|
||||
return permission in ROLE_PERMISSIONS.get(role, set())
|
||||
|
||||
def has_board_permission(
|
||||
self,
|
||||
user: User,
|
||||
permission: Permission,
|
||||
board_id: str,
|
||||
) -> bool:
|
||||
"""检查用户在特定板块的权限"""
|
||||
if not self.has_permission(user, permission):
|
||||
return False
|
||||
|
||||
# 版主只能管理自己板块
|
||||
if role == UserRole.MODERATOR:
|
||||
return board_id in user.moderated_boards
|
||||
|
||||
return True
|
||||
|
||||
async def can_edit_post(self, user: User, post: ForumPost) -> bool:
|
||||
"""检查用户是否可以编辑帖子"""
|
||||
if user.role == UserRole.ADMIN:
|
||||
return True
|
||||
if user.role == UserRole.MODERATOR:
|
||||
return post.board_id in user.moderated_boards
|
||||
return post.user_id == user.id
|
||||
|
||||
async def can_delete_post(self, user: User, post: ForumPost) -> bool:
|
||||
"""检查用户是否可以删除帖子"""
|
||||
if user.role == UserRole.ADMIN:
|
||||
return True
|
||||
if user.role == UserRole.MODERATOR:
|
||||
return post.board_id in user.moderated_boards
|
||||
return post.user_id == user.id
|
||||
|
||||
async def ban_user(
|
||||
self,
|
||||
admin: User,
|
||||
target_user_id: str,
|
||||
reason: str,
|
||||
duration: Optional[timedelta] = None,
|
||||
) -> None:
|
||||
"""禁言用户"""
|
||||
if admin.role != UserRole.ADMIN:
|
||||
raise PermissionError("需要管理员权限")
|
||||
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == target_user_id)
|
||||
)
|
||||
target_user = result.scalar_one_or_none()
|
||||
|
||||
if not target_user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
target_user.is_forum_banned = True
|
||||
target_user.forum_ban_until = (
|
||||
datetime.utcnow() + duration if duration else None
|
||||
)
|
||||
|
||||
# 记录操作日志
|
||||
await self._log_action(
|
||||
admin=admin,
|
||||
action="ban_user",
|
||||
target_id=target_user_id,
|
||||
details={"reason": reason, "duration": str(duration)},
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 依赖注入
|
||||
|
||||
```python
|
||||
# 在 ForumService 中注入
|
||||
class ForumService:
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
lock_manager: ForumLockManager,
|
||||
permission_service: PermissionService,
|
||||
):
|
||||
self.db = db
|
||||
self.lock = lock_manager
|
||||
self.perms = permission_service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 操作日志
|
||||
|
||||
### 5.1 日志模型
|
||||
|
||||
```python
|
||||
class ForumLog(BaseModel):
|
||||
__tablename__ = "forum_logs"
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
user_id: str # 操作者
|
||||
action: str # 操作类型
|
||||
target_type: str # post/reply/board/user/tag
|
||||
target_id: str # 目标 ID
|
||||
details: Optional[str] = None # JSON 详情
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
```
|
||||
|
||||
### 5.2 日志记录
|
||||
|
||||
```python
|
||||
async def _log_action(
|
||||
self,
|
||||
admin: User,
|
||||
action: str,
|
||||
target_id: str,
|
||||
details: Optional[dict] = None,
|
||||
request: Optional[Request] = None,
|
||||
) -> None:
|
||||
"""记录操作日志"""
|
||||
log = ForumLog(
|
||||
user_id=admin.id,
|
||||
action=action,
|
||||
target_type=action.split(":")[0],
|
||||
target_id=target_id,
|
||||
details=json.dumps(details) if details else None,
|
||||
ip_address=request.client.host if request else None,
|
||||
user_agent=request.headers.get("user-agent") if request else None,
|
||||
)
|
||||
self.db.add(log)
|
||||
await self.db.commit()
|
||||
|
||||
|
||||
# 日志操作类型
|
||||
class ForumAction(str, Enum):
|
||||
POST_CREATE = "post:create"
|
||||
POST_UPDATE = "post:update"
|
||||
POST_DELETE = "post:delete"
|
||||
POST_PIN = "post:pin"
|
||||
POST_LOCK = "post:lock"
|
||||
REPLY_CREATE = "reply:create"
|
||||
REPLY_UPDATE = "reply:update"
|
||||
REPLY_DELETE = "reply:delete"
|
||||
USER_BAN = "user:ban"
|
||||
USER_UNBAN = "user:unban"
|
||||
SCORE_CHANGE = "score:change"
|
||||
BOARD_CREATE = "board:create"
|
||||
BOARD_UPDATE = "board:update"
|
||||
BOARD_DELETE = "board:delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 积分系统
|
||||
|
||||
### 6.1 积分规则
|
||||
|
||||
```python
|
||||
SCORE_RULES = {
|
||||
# 帖子相关
|
||||
"post_create": 5, # 发帖
|
||||
"post_delete": -5, # 删除帖子
|
||||
"post_liked": 2, # 帖子被点赞
|
||||
"post_best": 10, # 帖子被设为精华
|
||||
|
||||
# 回复相关
|
||||
"reply_create": 2, # 回复
|
||||
"reply_delete": -2, # 删除回复
|
||||
"reply_liked": 1, # 回复被点赞
|
||||
"reply_best": 5, # 回复被设为最佳
|
||||
"reply_adopted": 10, # 提问被采纳
|
||||
|
||||
# 活跃相关
|
||||
"daily_login": 1, # 每日登录
|
||||
"daily_post": 3, # 每日首次发帖
|
||||
"daily_reply": 1, # 每日首次回复
|
||||
}
|
||||
|
||||
|
||||
class ScoreService:
|
||||
"""积分服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def add_score(
|
||||
self,
|
||||
user_id: str,
|
||||
action: str,
|
||||
reason: str,
|
||||
) -> int:
|
||||
"""增加积分"""
|
||||
score_delta = SCORE_RULES.get(action, 0)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
user.forum_score += score_delta
|
||||
|
||||
# 更新统计
|
||||
if action.startswith("post_"):
|
||||
user.post_count += 1
|
||||
elif action.startswith("reply_"):
|
||||
user.reply_count += 1
|
||||
|
||||
if action == "post_liked":
|
||||
user.like_received += 1
|
||||
elif action == "reply_liked":
|
||||
user.like_received += 1
|
||||
elif action == "reply_best":
|
||||
user.best_reply_count += 1
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return score_delta
|
||||
|
||||
async def get_leaderboard(
|
||||
self,
|
||||
limit: int = 10,
|
||||
period: str = "all", # all/month/week
|
||||
) -> List[dict]:
|
||||
"""获取积分排行榜"""
|
||||
query = select(User).where(User.forum_score > 0)
|
||||
|
||||
if period == "month":
|
||||
# 本月排行
|
||||
pass
|
||||
elif period == "week":
|
||||
# 本周排行
|
||||
pass
|
||||
|
||||
query = query.order_by(desc(User.forum_score)).limit(limit)
|
||||
result = await self.db.execute(query)
|
||||
|
||||
return [
|
||||
{
|
||||
"rank": i + 1,
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"avatar": user.avatar,
|
||||
"score": user.forum_score,
|
||||
}
|
||||
for i, user in enumerate(result.scalars().all())
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 端点
|
||||
|
||||
### 7.1 管理端点
|
||||
|
||||
```python
|
||||
@router.post("/admin/ban/{user_id}")
|
||||
async def ban_user(
|
||||
user_id: str,
|
||||
reason: str,
|
||||
duration: Optional[int] = None, # 天数
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""禁言用户(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
perm_service = PermissionService(db)
|
||||
await perm_service.ban_user(
|
||||
admin=current_user,
|
||||
target_user_id=user_id,
|
||||
reason=reason,
|
||||
duration=timedelta(days=duration) if duration else None,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/admin/unban/{user_id}")
|
||||
async def unban_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""解除禁言"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
perm_service = PermissionService(db)
|
||||
await perm_service.unban_user(current_user, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/admin/logs")
|
||||
async def get_logs(
|
||||
target_id: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查看操作日志(仅管理员/版主)"""
|
||||
if current_user.role not in ["admin", "moderator"]:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
# 查询日志...
|
||||
return {"logs": [], "total": 0}
|
||||
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def get_leaderboard(
|
||||
limit: int = 10,
|
||||
period: str = "all",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取积分排行榜"""
|
||||
score_service = ScoreService(db)
|
||||
return await score_service.get_leaderboard(limit, period)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 扩展 User 模型 | 🟢 高 |
|
||||
| 2 | 创建 PermissionService | 🟢 高 |
|
||||
| 3 | 实现权限检查装饰器 | 🟢 高 |
|
||||
| 4 | 创建 ForumLog 模型 | 🟡 中 |
|
||||
| 5 | 实现操作日志记录 | 🟡 中 |
|
||||
| 6 | 创建 ScoreService | 🟡 中 |
|
||||
| 7 | 实现积分规则 | 🟡 中 |
|
||||
| 8 | 扩展管理 API | 🟡 中 |
|
||||
| 9 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `models/user.py` | 扩展角色和统计字段 |
|
||||
| `models/forum.py` | 新增 ForumLog |
|
||||
| `services/permission_service.py` | 新增 |
|
||||
| `services/score_service.py` | 新增 |
|
||||
| `services/forum_service.py` | 集成权限检查 |
|
||||
| `routers/forum.py` | 扩展管理端点 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| User 模型扩展 | 0.5 天 |
|
||||
| PermissionService | 0.5 天 |
|
||||
| 操作日志 | 0.5 天 |
|
||||
| ScoreService | 0.5 天 |
|
||||
| API 端点 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **3 天** |
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- [ ] User 模型正确存储角色和积分
|
||||
- [ ] PermissionService 可正确检查权限
|
||||
- [ ] 权限不足时返回 403
|
||||
- [ ] 所有管理操作记录日志
|
||||
- [ ] 积分根据规则正确增减
|
||||
- [ ] 排行榜正确排序
|
||||
- [ ] 禁言功能正常工作
|
||||
- [ ] 单元测试覆盖核心逻辑
|
||||
652
development-doc/plan/forum-update/phase-f-4-ai-integration.md
Normal file
652
development-doc/plan/forum-update/phase-f-4-ai-integration.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# Phase F.4:AI 集成
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:F.3(待完成)
|
||||
前置:services/forum_ai_service.py
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
为 Jarvis Forum 集成 AI 能力:
|
||||
|
||||
- AI 自动回复
|
||||
- 帖子摘要生成
|
||||
- 智能分类打标
|
||||
- Agent 自主发帖
|
||||
|
||||
---
|
||||
|
||||
## 2. Forum AI Service
|
||||
|
||||
### 2.1 服务架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ForumAIService │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ AutoReply │ │ Summary │ │ SmartTagging │ │
|
||||
│ │ Service │ │ Service │ │ Service │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼───────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────┴───────────┐ │
|
||||
│ │ LLM Service │ │
|
||||
│ │ (复用 Agent LLM) │ │
|
||||
│ └───────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心服务
|
||||
|
||||
```python
|
||||
class ForumAIService:
|
||||
"""论坛 AI 服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_service: LLMService,
|
||||
config: ForumAIConfig,
|
||||
):
|
||||
self.llm = llm_service
|
||||
self.config = config
|
||||
|
||||
# === 自动回复 ===
|
||||
async def generate_auto_reply(
|
||||
self,
|
||||
post: ForumPost,
|
||||
replies: List[ForumReply],
|
||||
) -> Optional[str]:
|
||||
"""生成自动回复"""
|
||||
if not self.config.auto_reply_enabled:
|
||||
return None
|
||||
|
||||
# 检查是否需要回复
|
||||
if not self._should_auto_reply(post):
|
||||
return None
|
||||
|
||||
# 构建上下文
|
||||
context = self._build_reply_context(post, replies)
|
||||
|
||||
# 生成回复
|
||||
prompt = self._build_reply_prompt(context)
|
||||
response = await self.llm.agenerate(prompt)
|
||||
|
||||
return response.content if response else None
|
||||
|
||||
def _should_auto_reply(self, post: ForumPost) -> bool:
|
||||
"""判断是否应该自动回复"""
|
||||
# 指令类帖子不自动回复
|
||||
if post.category == "instruction":
|
||||
return False
|
||||
|
||||
# 有 AI 回复的不重复回复
|
||||
if any(r.is_ai_reply for r in post.replies):
|
||||
return False
|
||||
|
||||
# 检查时间窗口
|
||||
if post.last_reply_at:
|
||||
hours_since = (datetime.utcnow() - post.last_reply_at).total_seconds() / 3600
|
||||
if hours_since < self.config.auto_reply_min_hours:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _build_reply_context(
|
||||
self,
|
||||
post: ForumPost,
|
||||
replies: List[ForumReply],
|
||||
) -> str:
|
||||
"""构建回复上下文"""
|
||||
context = f"""## 帖子信息
|
||||
标题: {post.title}
|
||||
内容: {post.content[:500]}...
|
||||
|
||||
## 回复列表
|
||||
"""
|
||||
for reply in replies[-5:]: # 最近 5 条
|
||||
context += f"- {reply.user_id}: {reply.content[:200]}...\n"
|
||||
|
||||
return context
|
||||
|
||||
def _build_reply_prompt(self, context: str) -> str:
|
||||
"""构建回复提示词"""
|
||||
return f"""你是一个友好的社区助手。请根据以下帖子内容,生成一条有帮助的回复。
|
||||
|
||||
{context}
|
||||
|
||||
要求:
|
||||
1. 回复要友好、专业、有帮助
|
||||
2. 不要重复已有的观点
|
||||
3. 如果是问题,尽量给出建设性的建议
|
||||
4. 回复长度控制在 100-300 字
|
||||
5. 不要使用 Markdown 格式,直接输出文本
|
||||
|
||||
回复:"""
|
||||
|
||||
# === 摘要生成 ===
|
||||
async def generate_summary(
|
||||
self,
|
||||
content: str,
|
||||
max_length: int = 200,
|
||||
) -> str:
|
||||
"""生成帖子摘要"""
|
||||
if len(content) <= max_length:
|
||||
return content
|
||||
|
||||
prompt = f"""请为以下内容生成一个简洁的摘要,不超过 {max_length} 字。
|
||||
|
||||
内容:
|
||||
{content}
|
||||
|
||||
摘要:"""
|
||||
|
||||
response = await self.llm.agenerate(prompt)
|
||||
return response.content if response else content[:max_length]
|
||||
|
||||
# === 智能打标 ===
|
||||
async def suggest_tags(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
existing_tags: List[str],
|
||||
) -> List[str]:
|
||||
"""智能推荐标签"""
|
||||
prompt = f"""请根据以下帖子内容,推荐 3-5 个最合适的标签。
|
||||
|
||||
标题: {title}
|
||||
内容: {content[:1000]}...
|
||||
|
||||
已有标签: {', '.join(existing_tags) if existing_tags else '无'}
|
||||
|
||||
要求:
|
||||
1. 标签要简洁,2-4 个字
|
||||
2. 选择最相关的标签
|
||||
3. 如果已有标签合适可以保留
|
||||
4. 只输出标签,用逗号分隔,不要其他内容
|
||||
|
||||
标签:"""
|
||||
|
||||
response = await self.llm.agenerate(prompt)
|
||||
if not response:
|
||||
return existing_tags
|
||||
|
||||
# 解析标签
|
||||
tags = [t.strip() for t in response.content.split(",")]
|
||||
return tags[:5] # 最多 5 个
|
||||
|
||||
# === 智能分类 ===
|
||||
async def classify_category(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
"""智能分类"""
|
||||
categories = ["instruction", "discussion", "question", "praise", "bug", "other"]
|
||||
|
||||
prompt = f"""请为以下帖子选择一个最合适的分类。
|
||||
|
||||
标题: {title}
|
||||
内容: {content[:500]}...
|
||||
|
||||
可选分类:
|
||||
- instruction: 指令/任务请求
|
||||
- discussion: 讨论/分享
|
||||
- question: 问题/求助
|
||||
- praise: 表扬/感谢
|
||||
- bug: Bug 反馈
|
||||
- other: 其他
|
||||
|
||||
请直接输出分类名称,不要其他内容。
|
||||
|
||||
分类:"""
|
||||
|
||||
response = await self.llm.agenerate(prompt)
|
||||
if not response:
|
||||
return "other"
|
||||
|
||||
category = response.content.strip().lower()
|
||||
if category not in categories:
|
||||
return "other"
|
||||
|
||||
return category
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary Service
|
||||
|
||||
### 3.1 服务实现
|
||||
|
||||
```python
|
||||
class SummaryService:
|
||||
"""帖子摘要服务"""
|
||||
|
||||
def __init__(self, cache: ForumCache):
|
||||
self.cache = cache
|
||||
|
||||
async def get_post_summary(
|
||||
self,
|
||||
post_id: str,
|
||||
post: ForumPost,
|
||||
) -> str:
|
||||
"""获取帖子摘要(带缓存)"""
|
||||
cache_key = f"summary:{post_id}"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 生成摘要
|
||||
summary = await self._generate_summary(post)
|
||||
|
||||
# 存入缓存(1 小时)
|
||||
await self.cache.set(cache_key, summary, ttl=3600)
|
||||
|
||||
return summary
|
||||
|
||||
async def get_thread_summary(
|
||||
self,
|
||||
post: ForumPost,
|
||||
replies: List[ForumReply],
|
||||
) -> str:
|
||||
"""获取帖子串摘要(主帖+回复摘要)"""
|
||||
summaries = [await self.get_post_summary(post.id, post)]
|
||||
|
||||
# 汇总回复要点
|
||||
if len(replies) > 0:
|
||||
summary_prompt = f"""请总结以下回复的核心观点,用 50 字以内概括。
|
||||
|
||||
回复列表:
|
||||
{self._format_replies(replies[:10])}
|
||||
|
||||
总结:"""
|
||||
|
||||
response = await self.llm.agenerate(summary_prompt)
|
||||
if response:
|
||||
summaries.append(f"回复要点: {response.content}")
|
||||
|
||||
return "\n\n".join(summaries)
|
||||
|
||||
async def invalidate_summary(self, post_id: str) -> None:
|
||||
"""清除摘要缓存"""
|
||||
await self.cache.invalidate(f"summary:{post_id}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent 自主发帖
|
||||
|
||||
### 4.1 Agent Forum 工具
|
||||
|
||||
```python
|
||||
# agents/tools/forum_tools.py
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class ForumTools:
|
||||
"""Forum Agent 工具集"""
|
||||
|
||||
def __init__(self, forum_service: ForumService, ai_service: ForumAIService):
|
||||
self.forum = forum_service
|
||||
self.ai = ai_service
|
||||
|
||||
@tool
|
||||
async def create_forum_post(
|
||||
title: str,
|
||||
content: str,
|
||||
board_id: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> dict:
|
||||
"""创建论坛帖子
|
||||
|
||||
参数:
|
||||
- title: 帖子标题
|
||||
- content: 帖子内容
|
||||
- board_id: 板块 ID(可选)
|
||||
- category: 分类(可选)
|
||||
- tags: 标签列表(可选)
|
||||
"""
|
||||
data = ForumPostCreate(
|
||||
title=title,
|
||||
content=content,
|
||||
board_id=board_id,
|
||||
category=category,
|
||||
tags=tags or [],
|
||||
)
|
||||
post = await self.forum.create_post(agent_id=AGENT_ID, data=data)
|
||||
return {"post_id": post.id, "title": post.title}
|
||||
|
||||
@tool
|
||||
async def reply_to_post(
|
||||
post_id: str,
|
||||
content: str,
|
||||
) -> dict:
|
||||
"""回复帖子
|
||||
|
||||
参数:
|
||||
- post_id: 帖子 ID
|
||||
- content: 回复内容
|
||||
"""
|
||||
data = ForumReplyCreate(content=content)
|
||||
reply = await self.forum.create_reply(
|
||||
post_id=post_id,
|
||||
agent_id=AGENT_ID,
|
||||
data=data,
|
||||
)
|
||||
return {"reply_id": reply.id, "floor": reply.floor}
|
||||
|
||||
@tool
|
||||
async def search_forum_posts(
|
||||
query: str,
|
||||
board_id: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> List[dict]:
|
||||
"""搜索论坛帖子
|
||||
|
||||
参数:
|
||||
- query: 搜索关键词
|
||||
- board_id: 限定板块(可选)
|
||||
- category: 限定分类(可选)
|
||||
- limit: 返回数量(默认 10)
|
||||
"""
|
||||
posts = await self.forum.search_posts(
|
||||
query=query,
|
||||
board_id=board_id,
|
||||
category=category,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
{"id": p.id, "title": p.title, "summary": p.content[:100]}
|
||||
for p in posts
|
||||
]
|
||||
|
||||
@tool
|
||||
async def get_forum_trending(
|
||||
board_id: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[dict]:
|
||||
"""获取热门帖子
|
||||
|
||||
参数:
|
||||
- board_id: 板块 ID(可选)
|
||||
- limit: 返回数量(默认 5)
|
||||
"""
|
||||
posts = await self.forum.get_trending(
|
||||
board_id=board_id,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"reply_count": p.reply_count,
|
||||
"view_count": p.view_count,
|
||||
}
|
||||
for p in posts
|
||||
]
|
||||
```
|
||||
|
||||
### 4.2 Agent 配置
|
||||
|
||||
```python
|
||||
# agents/prompts/forum_agent.py
|
||||
FORUM_AGENT_PROMPT = """你是一个活跃的社区成员,可以帮助用户解决问题和参与讨论。
|
||||
|
||||
## 你的能力
|
||||
1. 在论坛发帖分享信息或见解
|
||||
2. 回复其他用户的帖子
|
||||
3. 搜索论坛内容
|
||||
4. 查看热门帖子
|
||||
|
||||
## 行为规则
|
||||
1. 只在有帮助时才发帖/回复,不要刷屏
|
||||
2. 回复要专业、有建设性
|
||||
3. 如果用户的问题已经解决,不要重复回答
|
||||
4. 遇到 Bug 或问题可以主动发帖提醒
|
||||
5. 定期查看论坛,如果有重要帖子可以参与讨论
|
||||
|
||||
## 当前时间
|
||||
{current_time}
|
||||
|
||||
## 用户信息
|
||||
用户名: {username}
|
||||
积分: {forum_score}
|
||||
"""
|
||||
|
||||
# Agent 可用的 Forum 工具
|
||||
FORUM_TOOLS = [
|
||||
create_forum_post,
|
||||
reply_to_post,
|
||||
search_forum_posts,
|
||||
get_forum_trending,
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 定时任务
|
||||
|
||||
### 5.1 自动回复任务
|
||||
|
||||
```python
|
||||
# tasks/forum_auto_reply.py
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
|
||||
async def auto_reply_task():
|
||||
"""自动回复任务"""
|
||||
# 获取需要回复的帖子
|
||||
posts = await forum_service.get_pending_posts(
|
||||
min_age_hours=settings.auto_reply_min_hours,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
for post in posts:
|
||||
replies = await forum_service.get_replies(post.id)
|
||||
|
||||
# 生成回复
|
||||
response = await ai_service.generate_auto_reply(post, replies)
|
||||
|
||||
if response:
|
||||
# 发布回复
|
||||
await forum_service.create_reply(
|
||||
post_id=post.id,
|
||||
agent_id=AGENT_ID,
|
||||
data=ForumReplyCreate(content=response),
|
||||
)
|
||||
|
||||
# 更新回复计数
|
||||
await forum_service.increment_reply_count(post.id)
|
||||
|
||||
|
||||
def setup_forum_scheduler(scheduler: AsyncIOScheduler):
|
||||
"""配置论坛定时任务"""
|
||||
# 每小时检查一次自动回复
|
||||
scheduler.add_job(
|
||||
auto_reply_task,
|
||||
"interval",
|
||||
hours=1,
|
||||
id="forum_auto_reply",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 配置项
|
||||
|
||||
### 6.1 AI 配置
|
||||
|
||||
```python
|
||||
class ForumAIConfig:
|
||||
"""Forum AI 配置"""
|
||||
|
||||
# 自动回复
|
||||
auto_reply_enabled: bool = True
|
||||
auto_reply_min_hours: int = 24 # 帖子发布 N 小时后才自动回复
|
||||
auto_reply_max_per_day: int = 10 # 每天最多自动回复 N 条
|
||||
|
||||
# 摘要生成
|
||||
summary_enabled: bool = True
|
||||
summary_max_length: int = 200
|
||||
summary_cache_ttl: int = 3600 # 缓存 1 小时
|
||||
|
||||
# 智能打标
|
||||
smart_tagging_enabled: bool = True
|
||||
smart_tagging_max_tags: int = 5
|
||||
|
||||
# 智能分类
|
||||
smart_classification_enabled: bool = True
|
||||
|
||||
|
||||
# settings.py
|
||||
class Settings(BaseSettings):
|
||||
# Forum AI
|
||||
forum_ai_auto_reply: bool = True
|
||||
forum_ai_summary: bool = True
|
||||
forum_ai_smart_tagging: bool = True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 端点
|
||||
|
||||
### 7.1 AI 相关端点
|
||||
|
||||
```python
|
||||
@router.post("/posts/{post_id}/generate-summary")
|
||||
async def generate_post_summary(
|
||||
post_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生成帖子摘要"""
|
||||
result = await db.execute(
|
||||
select(ForumPost).where(ForumPost.id == post_id)
|
||||
)
|
||||
post = result.scalar_one_or_none()
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
|
||||
ai_service = ForumAIService(llm_service, config)
|
||||
summary = await ai_service.generate_summary(post.content)
|
||||
|
||||
return {"summary": summary}
|
||||
|
||||
|
||||
@router.post("/posts/suggest-tags")
|
||||
async def suggest_post_tags(
|
||||
title: str,
|
||||
content: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""推荐帖子标签"""
|
||||
ai_service = ForumAIService(llm_service, config)
|
||||
tags = await ai_service.suggest_tags(title, content, [])
|
||||
|
||||
return {"tags": tags}
|
||||
|
||||
|
||||
@router.post("/posts/classify")
|
||||
async def classify_post(
|
||||
title: str,
|
||||
content: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分类帖子"""
|
||||
ai_service = ForumAIService(llm_service, config)
|
||||
category = await ai_service.classify_category(title, content)
|
||||
|
||||
return {"category": category}
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/ai-status")
|
||||
async def get_ai_status(
|
||||
post_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取帖子 AI 状态"""
|
||||
result = await db.execute(
|
||||
select(ForumPost).where(ForumPost.id == post_id)
|
||||
)
|
||||
post = result.scalar_one_or_none()
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||
|
||||
# 检查 AI 回复状态
|
||||
has_ai_reply = any(r.is_ai_reply for r in post.replies)
|
||||
summary_cached = await cache.get(f"summary:{post_id}")
|
||||
|
||||
return {
|
||||
"has_ai_reply": has_ai_reply,
|
||||
"summary_available": bool(summary_cached),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 创建 ForumAIService | 🟢 高 |
|
||||
| 2 | 实现自动回复 | 🟢 高 |
|
||||
| 3 | 实现摘要生成 | 🟡 中 |
|
||||
| 4 | 实现智能打标 | 🟡 中 |
|
||||
| 5 | 实现智能分类 | 🟡 中 |
|
||||
| 6 | 创建 ForumTools | 🟢 高 |
|
||||
| 7 | 配置定时任务 | 🟡 中 |
|
||||
| 8 | 扩展 API 端点 | 🟡 中 |
|
||||
| 9 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `services/forum_ai_service.py` | 新增 |
|
||||
| `services/summary_service.py` | 新增 |
|
||||
| `agents/tools/forum_tools.py` | 新增 |
|
||||
| `agents/prompts/forum_agent.py` | 新增 |
|
||||
| `tasks/forum_auto_reply.py` | 新增 |
|
||||
| `routers/forum.py` | 扩展 AI 端点 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| ForumAIService | 1 天 |
|
||||
| 自动回复 | 1 天 |
|
||||
| 摘要/打标/分类 | 1 天 |
|
||||
| ForumTools | 1 天 |
|
||||
| 定时任务 | 0.5 天 |
|
||||
| API 端点 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **5.5 天** |
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- [ ] ForumAIService 可正常调用 LLM
|
||||
- [ ] 自动回复功能正常工作
|
||||
- [ ] 摘要生成功能正常
|
||||
- [ ] 智能打标推荐准确
|
||||
- [ ] 智能分类推荐准确
|
||||
- [ ] ForumTools 可被 Agent 调用
|
||||
- [ ] 定时任务正常执行
|
||||
- [ ] API 端点正常工作
|
||||
- [ ] 单元测试覆盖核心逻辑
|
||||
155
development-doc/plan/memory-update/README.md
Normal file
155
development-doc/plan/memory-update/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Jarvis Memory 升级计划索引
|
||||
|
||||
本目录用于存放 Jarvis 记忆系统的分阶段升级规划文档。
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、实施顺序 |
|
||||
| `phase-m-0-current-state.md` | 当前现状、问题、目标架构 |
|
||||
| `phase-m-1-importance-scoring.md` | 重要性评分系统 |
|
||||
| `phase-m-2-forgetting-system.md` | 遗忘曲线系统 |
|
||||
| `phase-m-3-proactive-reminder.md` | 主动提醒系统 |
|
||||
| `checklist.md` | 执行清单 |
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先读 `phase-m-0-current-state.md`
|
||||
2. 再按顺序阅读 phase m-1 ~ m-3
|
||||
3. 实施时严格按阶段推进
|
||||
4. 参考 `checklist.md` 进行任务追踪
|
||||
|
||||
---
|
||||
|
||||
## 总体升级原则
|
||||
|
||||
1. **频率追踪** - 每次交互更新记忆频率
|
||||
2. **重要性分层** - 高频/情绪/影响面 → 重要记忆
|
||||
3. **遗忘曲线** - 低频记忆自然衰减
|
||||
4. **主动关心** - 定期生成提醒,而非被动响应
|
||||
5. **可独立推进** - Phase M 可与 Agent Phase 1-5 并行
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览图
|
||||
|
||||
```
|
||||
M.0 ──────────────────────────────────────────────────────────────┐
|
||||
│ 现状与目标 │
|
||||
│ - 当前记忆架构分析 │
|
||||
│ - 短板识别 │
|
||||
│ - 拟人记忆目标 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
M.1 ──────────────────────────────────────────────────────────────┐
|
||||
│ 重要性评分系统 │
|
||||
│ - MemoryFrequencyTracker (频率追踪) │
|
||||
│ - EmotionAnalyzer (情绪分析) │
|
||||
│ - ImpactScorer (影响面评估) │
|
||||
│ │
|
||||
│ 核心文件: services/memory/importance_scorer.py │
|
||||
│ 工作量: 4 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
M.2 ──────────────────────────────────────────────────────────────┐
|
||||
│ 遗忘曲线系统 │
|
||||
│ - ForgettingCurve (遗忘曲线) │
|
||||
│ - MemoryDecay (记忆衰减) │
|
||||
│ - ReinforcementTrigger (强化触发) │
|
||||
│ │
|
||||
│ 核心文件: services/memory/forgetting_curve.py │
|
||||
│ 依赖: M.1 │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
M.3 ──────────────────────────────────────────────────────────────┐
|
||||
│ 主动提醒系统 │
|
||||
│ - DailyDigestGenerator (每日摘要) │
|
||||
│ - ReminderScheduler (提醒调度) │
|
||||
│ - ProactiveMemoryInformer (主动提醒) │
|
||||
│ │
|
||||
│ 核心文件: services/memory/proactive_reminder.py │
|
||||
│ 依赖: M.1, M.2 │
|
||||
│ 工作量: 5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心借鉴
|
||||
|
||||
| 借鉴点 | 来源 | 难度 |
|
||||
|--------|------|------|
|
||||
| 频率追踪 | 儿童认知发育模型 | 🟢 低 |
|
||||
| 艾宾浩斯遗忘曲线 | 心理学研究 | 🟢 低 |
|
||||
| 重要性评分 | Jarvis 自身需求 | 🟡 中 |
|
||||
| 主动提醒 | 儿童认知发育模型 | 🟡 中 |
|
||||
|
||||
**注:本升级不借鉴 VCPToolBox,因为 VCPToolBox 解决的是「检索精度」问题,而本升级解决的是「记忆价值判断」问题。**
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
M.0 → M.1 → M.2 → M.3
|
||||
│ │ │
|
||||
│ │ └── 主动提醒系统
|
||||
│ └── 遗忘曲线系统
|
||||
└── 现状与目标
|
||||
```
|
||||
|
||||
**注意:** M.1 是基础,M.2 和 M.3 都依赖 M.1。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更追踪
|
||||
|
||||
| Phase | 新增文件 | 修改文件 |
|
||||
|-------|---------|---------|
|
||||
| M.1 | `services/memory/importance_scorer.py`, `services/memory/frequency_tracker.py`, `services/memory/emotion_analyzer.py`, `tests/test_importance_scorer.py` | `models/memory.py`, `services/memory_service.py` |
|
||||
| M.2 | `services/memory/forgetting_curve.py`, `tests/test_forgetting_curve.py` | `models/memory.py`, `services/memory_service.py` |
|
||||
| M.3 | `services/memory/daily_digest.py`, `services/memory/reminder_scheduler.py`, `tests/test_proactive_reminder.py` | `services/memory_service.py`, `services/scheduler_service.py` |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 1-5 的关系
|
||||
|
||||
| Agent Phase | Memory 协作内容 |
|
||||
|-------------|----------------|
|
||||
| Phase 1 | Memory 追踪用户交互频率 |
|
||||
| Phase 2 | Memory 服务被 Librarian Agent 调用 |
|
||||
| Phase 3 | 支持动态协作时的记忆共享 |
|
||||
| Phase 4 | Memory 重要性可视化 |
|
||||
| Phase 5 | 高级记忆关联分析 |
|
||||
| **Phase M** | **独立 Memory 升级路径,可与 Phase 1-5 并行推进** |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 注意事项 | 说明 |
|
||||
|---------|------|
|
||||
| M.1 是基础 | M.2 和 M.3 都依赖 M.1 的重要性评分 |
|
||||
| 渐进式遗忘 | 不是删除,是降权和归档 |
|
||||
| 主动提醒需用户授权 | 提醒推送需要用户明确开启 |
|
||||
| 不改变现有检索逻辑 | Memory 升级是独立于 RAG 的 |
|
||||
|
||||
---
|
||||
|
||||
## 目标:拟人化记忆
|
||||
|
||||
```
|
||||
现在的 Jarvis:
|
||||
用户问什么,Jarvis 答什么,不问就不说
|
||||
|
||||
升级后的 Jarvis:
|
||||
- 知道什么对你重要(频率+情绪+影响面)
|
||||
- 知道什么是你的痛点(反复问的问题)
|
||||
- 会主动提醒你关心的事(不是等用户问)
|
||||
- 知道什么可以忘记(低频记忆自然衰减)
|
||||
```
|
||||
410
development-doc/plan/memory-update/checklist.md
Normal file
410
development-doc/plan/memory-update/checklist.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Jarvis Memory 升级执行清单
|
||||
|
||||
日期:2026-04-04
|
||||
状态:执行清单
|
||||
升级方向:拟人化记忆系统
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 完成前使用 `- [ ]`
|
||||
- 完成后改成 `- [x]`
|
||||
- Day M.2 默认依赖 Day M.1 的重要性评分完成后再推进
|
||||
- Day M.3 默认依赖 Day M.1 和 M.2 完成后再推进
|
||||
|
||||
---
|
||||
|
||||
## Day M.1:重要性评分系统(4天)
|
||||
|
||||
Day M.1 目标:让 Jarvis 知道「什么对你重要」。
|
||||
|
||||
### Task M.1.1:实现 FrequencyTracker
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/frequency_tracker.py`
|
||||
|
||||
- [ ] 实现 `FrequencyTracker` 类
|
||||
|
||||
- [ ] 实现 `increment()` 方法
|
||||
```python
|
||||
def increment(self, memory: UserMemory) -> UserMemory:
|
||||
memory.frequency_count += 1
|
||||
memory.last_recalled_at = datetime.now()
|
||||
return memory
|
||||
```
|
||||
|
||||
- [ ] 实现 `get_time_decay()` 方法
|
||||
|
||||
### Task M.1.2:实现 EmotionAnalyzer
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/emotion_analyzer.py`
|
||||
|
||||
- [ ] 实现 `EmotionAnalyzer` 类
|
||||
|
||||
- [ ] 定义 `EMOTION_KEYWORDS` 字典
|
||||
```python
|
||||
EMOTION_KEYWORDS = {
|
||||
"急": 1.0,
|
||||
"很重要": 0.9,
|
||||
"困扰": 0.8,
|
||||
"担心": 0.7,
|
||||
"想解决": 0.6,
|
||||
"无所谓": 0.1,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] 实现 `extract()` 方法 - 从文本提取情绪关键词
|
||||
|
||||
- [ ] 实现 `calculate_score()` 方法 - 计算情绪分数
|
||||
|
||||
### Task M.1.3:实现 ImpactEvaluator
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/impact_evaluator.py`
|
||||
|
||||
- [ ] 实现 `ImpactEvaluator` 类
|
||||
|
||||
- [ ] 实现 `evaluate()` 方法
|
||||
```python
|
||||
def evaluate(self, memory: UserMemory) -> float:
|
||||
# 关联话题越多,影响面越大
|
||||
return min(1.0, len(memory.associated_topics) / IMPACT_THRESHOLD)
|
||||
```
|
||||
|
||||
### Task M.1.4:实现 ImportanceScorer
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/importance_scorer.py`
|
||||
|
||||
- [ ] 实现 `ImportanceScorer` 类
|
||||
|
||||
- [ ] 实现 `calculate_score()` 综合评分方法
|
||||
```python
|
||||
def calculate_score(self, memory: UserMemory) -> float:
|
||||
frequency = self.tracker.get_frequency_score(memory) * 0.35
|
||||
recency = self.tracker.get_recency_score(memory) * 0.20
|
||||
emotion = self.emotion_analyzer.calculate_score(memory) * 0.25
|
||||
impact = self.impact_evaluator.evaluate(memory) * 0.20
|
||||
return frequency + recency + emotion + impact
|
||||
```
|
||||
|
||||
- [ ] 实现 `get_importance_level()` 方法
|
||||
|
||||
- [ ] 实现 `should_escalate()` 方法
|
||||
|
||||
### Task M.1.5:修改 UserMemory 模型
|
||||
|
||||
- [ ] 修改 `backend/app/models/memory.py`
|
||||
|
||||
- [ ] 增加字段:
|
||||
```python
|
||||
frequency_count: int = 0
|
||||
last_recalled_at: DateTime = None
|
||||
emotion_tags: list[str] = []
|
||||
importance_score: float = 0.5
|
||||
importance_level: str = "medium"
|
||||
associated_topics: list[str] = []
|
||||
```
|
||||
|
||||
### Task M.1.6:集成到 MemoryService
|
||||
|
||||
- [ ] 修改 `backend/app/services/memory_service.py`
|
||||
|
||||
- [ ] 集成 `ImportanceScorer`
|
||||
|
||||
- [ ] 修改 `add_memory()` 方法计算重要性
|
||||
|
||||
- [ ] 修改 `recall_memories()` 方法按重要性排序
|
||||
|
||||
### Task M.1.7:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_importance_scorer.py`
|
||||
|
||||
- [ ] 测试频率追踪
|
||||
|
||||
- [ ] 测试情绪分析
|
||||
|
||||
- [ ] 测试重要性评分
|
||||
|
||||
- [ ] 测试重要性等级划分
|
||||
|
||||
### Day M.1 验收
|
||||
|
||||
- [ ] 频率追踪正常(recall_count 每次 +1)
|
||||
- [ ] 情绪识别准确(「急」「很重要」等能识别)
|
||||
- [ ] 重要性分数正确(高频+情绪 = importance >= 0.8)
|
||||
- [ ] 评分影响排序(高重要性记忆排在前面)
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
## Day M.2:遗忘曲线系统(3天)
|
||||
|
||||
Day M.2 目标:让 Jarvis 知道「什么可以忘记」。
|
||||
|
||||
### Task M.2.1:实现 ForgettingCurve
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/forgetting_curve.py`
|
||||
|
||||
- [ ] 实现 `ForgettingCurve` 类
|
||||
|
||||
- [ ] 实现 `calculate_decay()` 方法
|
||||
```python
|
||||
def calculate_decay(self, memory: UserMemory) -> float:
|
||||
half_life = self.get_half_life(memory)
|
||||
days = (datetime.now() - memory.last_accessed_at).days
|
||||
return exp(-days / half_life)
|
||||
```
|
||||
|
||||
- [ ] 实现 `get_half_life()` 方法(重要性影响半衰期)
|
||||
|
||||
### Task M.2.2:实现 MemoryDecay
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/memory_decay.py`
|
||||
|
||||
- [ ] 实现 `MemoryDecay` 类
|
||||
|
||||
- [ ] 实现 `should_archive()` 方法(decay < 0.2)
|
||||
|
||||
- [ ] 实现 `should_deprioritize()` 方法(decay < 0.5)
|
||||
|
||||
- [ ] 实现 `archive_memory()` 方法
|
||||
|
||||
- [ ] 实现 `restore_from_archive()` 方法
|
||||
|
||||
### Task M.2.3:实现 MemoryReinforcement
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/reinforcement.py`
|
||||
|
||||
- [ ] 实现 `MemoryReinforcement` 类
|
||||
|
||||
- [ ] 实现 `trigger()` 方法(召回时强化)
|
||||
|
||||
- [ ] 实现 `auto_reinforce()` 方法(定期强化 high 级别)
|
||||
|
||||
### Task M.2.4:修改 UserMemory 模型
|
||||
|
||||
- [ ] 修改 `backend/app/models/memory.py`
|
||||
|
||||
- [ ] 增加字段:
|
||||
```python
|
||||
decay_score: float = 1.0
|
||||
is_archived: bool = False
|
||||
last_accessed_at: DateTime = None
|
||||
archive_at: DateTime = None
|
||||
```
|
||||
|
||||
### Task M.2.5:集成到 MemoryService
|
||||
|
||||
- [ ] 修改 `backend/app/services/memory_service.py`
|
||||
|
||||
- [ ] 集成 ForgettingCurve
|
||||
|
||||
- [ ] 修改 recall_memories() 更新 last_accessed_at
|
||||
|
||||
- [ ] 集成 MemoryReinforcement
|
||||
|
||||
### Task M.2.6:添加调度任务
|
||||
|
||||
- [ ] 修改 `backend/app/services/scheduler_service.py`
|
||||
|
||||
- [ ] 添加每日遗忘检查(cron: 03:00)
|
||||
|
||||
- [ ] 添加每周强化任务(cron: 周一 04:00)
|
||||
|
||||
### Task M.2.7:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_forgetting_curve.py`
|
||||
|
||||
- [ ] 测试遗忘曲线计算
|
||||
|
||||
- [ ] 测试高重要性记忆衰减慢
|
||||
|
||||
- [ ] 测试归档/恢复
|
||||
|
||||
### Day M.2 验收
|
||||
|
||||
- [ ] 遗忘曲线正确(30 天后 decay ≈ 0.5)
|
||||
- [ ] 高重要性记忆衰减慢(high 衰减速度是 low 的 1/6)
|
||||
- [ ] 归档正常(decay < 0.2 自动归档)
|
||||
- [ ] 恢复正常(归档记忆可以恢复)
|
||||
- [ ] 调度任务正常(每日检查、周强化执行)
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
## Day M.3:主动提醒系统(6天)
|
||||
|
||||
Day M.3 目标:让 Jarvis 从「等用户问」变成「主动关心」。
|
||||
|
||||
### Task M.3.1:实现 DailyDigestGenerator
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/daily_digest.py`
|
||||
|
||||
- [ ] 实现 `DailyDigestGenerator` 类
|
||||
|
||||
- [ ] 定义 `DailyDigest` 数据类
|
||||
|
||||
- [ ] 实现 `generate()` 方法
|
||||
```python
|
||||
async def generate(self, user_id: int, date: date = None) -> DailyDigest:
|
||||
# 1. 获取今日对话摘要
|
||||
# 2. 获取高重要性记忆
|
||||
# 3. 获取待解答问题
|
||||
# 4. 生成建议
|
||||
```
|
||||
|
||||
- [ ] 实现 `get_recent_digests()` 方法
|
||||
|
||||
### Task M.3.2:实现 ReminderScheduler
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/reminder_scheduler.py`
|
||||
|
||||
- [ ] 定义 `Reminder` 数据类
|
||||
|
||||
- [ ] 实现 `ReminderScheduler` 类
|
||||
|
||||
- [ ] 实现 `create_reminder()` 方法
|
||||
|
||||
- [ ] 实现 `get_due_reminders()` 方法
|
||||
|
||||
- [ ] 实现 `snooze()` 方法
|
||||
|
||||
- [ ] 实现 `dismiss()` 方法
|
||||
|
||||
### Task M.3.3:实现 ProactiveInformer
|
||||
|
||||
- [ ] 新增 `backend/app/services/memory/proactive_informer.py`
|
||||
|
||||
- [ ] 实现 `ProactiveInformer` 类
|
||||
|
||||
- [ ] 定义 `TRIGGERS` 配置
|
||||
|
||||
- [ ] 定义 `INFORM_PROBABILITY` 配置
|
||||
|
||||
- [ ] 实现 `should_inform()` 方法
|
||||
|
||||
- [ ] 实现 `get_inform_message()` 方法
|
||||
|
||||
- [ ] 实现 `check_and_inform()` 方法
|
||||
|
||||
### Task M.3.4:创建提醒数据模型
|
||||
|
||||
- [ ] 修改数据库支持 `reminders` 表
|
||||
|
||||
- [ ] 新增 `backend/app/models/reminder.py`
|
||||
|
||||
- [ ] 或在现有模型文件中增加 Reminder 类
|
||||
|
||||
### Task M.3.5:集成到 MemoryService
|
||||
|
||||
- [ ] 修改 `backend/app/services/memory_service.py`
|
||||
|
||||
- [ ] 集成 DailyDigestGenerator
|
||||
|
||||
- [ ] 集成 ProactiveInformer
|
||||
|
||||
- [ ] 修改 recall_memories() 触发主动告知检查
|
||||
|
||||
### Task M.3.6:集成到 SchedulerService
|
||||
|
||||
- [ ] 修改 `backend/app/services/scheduler_service.py`
|
||||
|
||||
- [ ] 添加每日摘要生成(cron: 22:00)
|
||||
|
||||
- [ ] 添加提醒检查任务(cron: 每 15 分钟)
|
||||
|
||||
### Task M.3.7:前端 - 每日摘要展示
|
||||
|
||||
- [ ] 修改前端对话页面
|
||||
|
||||
- [ ] 新增每日摘要卡片组件
|
||||
|
||||
- [ ] 获取和展示今日摘要
|
||||
|
||||
### Task M.3.8:前端 - 主动提醒推送
|
||||
|
||||
- [ ] 新增主动提醒 Toast 组件
|
||||
|
||||
- [ ] 实现稍后/知道了按钮
|
||||
|
||||
- [ ] 推送 WebSocket 集成
|
||||
|
||||
### Task M.3.9:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_proactive_reminder.py`
|
||||
|
||||
- [ ] 测试每日摘要生成
|
||||
|
||||
- [ ] 测试提醒创建和调度
|
||||
|
||||
- [ ] 测试主动告知概率
|
||||
|
||||
### Day M.3 验收
|
||||
|
||||
- [ ] 每日摘要生成正常(22:00 自动生成)
|
||||
- [ ] 提醒创建正常(用户可创建提醒)
|
||||
- [ ] 提醒到期触发(定时推送)
|
||||
- [ ] 主动告知概率正确(按配置的概率触发)
|
||||
- [ ] 告知消息自然(像人说话,不生硬)
|
||||
- [ ] 用户可控制(可以关闭主动提醒)
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
## 总验收清单
|
||||
|
||||
### Phase M.1-M.3 必须完成
|
||||
|
||||
- [ ] 重要性评分系统正常工作
|
||||
- [ ] 遗忘曲线系统正常工作
|
||||
- [ ] 主动提醒系统正常工作
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
- [ ] 集成测试通过
|
||||
- [ ] 原有记忆功能无回退
|
||||
|
||||
---
|
||||
|
||||
## 总工作量估算
|
||||
|
||||
| Phase | 工作量 |
|
||||
|-------|--------|
|
||||
| M.1 重要性评分 | 4 天 |
|
||||
| M.2 遗忘曲线 | 3 天 |
|
||||
| M.3 主动提醒 | 6 天 |
|
||||
| **合计** | **13 天** |
|
||||
|
||||
---
|
||||
|
||||
## 产出清单
|
||||
|
||||
| 产出 | 对应 Phase |
|
||||
|------|-----------|
|
||||
| `services/memory/frequency_tracker.py` | M.1 |
|
||||
| `services/memory/emotion_analyzer.py` | M.1 |
|
||||
| `services/memory/impact_evaluator.py` | M.1 |
|
||||
| `services/memory/importance_scorer.py` | M.1 |
|
||||
| `services/memory/forgetting_curve.py` | M.2 |
|
||||
| `services/memory/memory_decay.py` | M.2 |
|
||||
| `services/memory/reinforcement.py` | M.2 |
|
||||
| `services/memory/daily_digest.py` | M.3 |
|
||||
| `services/memory/reminder_scheduler.py` | M.3 |
|
||||
| `services/memory/proactive_informer.py` | M.3 |
|
||||
| `models/memory.py` 更新 | M.1, M.2 |
|
||||
| `models/reminder.py` 新增 | M.3 |
|
||||
| 前端摘要卡片 | M.3 |
|
||||
| 前端提醒 Toast | M.3 |
|
||||
| 单元测试 > 80% | M.1, M.2, M.3 |
|
||||
| 集成测试通过 | M.1, M.2, M.3 |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 关系
|
||||
|
||||
| Agent Phase | Memory 协作内容 |
|
||||
|-------------|----------------|
|
||||
| Phase 1 | Memory 追踪用户交互频率 |
|
||||
| Phase 2 | Memory 服务被 Librarian Agent 调用 |
|
||||
| Phase 3 | 支持动态协作时的记忆共享 |
|
||||
| Phase 4 | Memory 重要性可视化 |
|
||||
| Phase 5 | 高级记忆关联分析 |
|
||||
|
||||
**Phase M 可与 Agent Phase 1-5 并行推进。**
|
||||
125
development-doc/plan/memory-update/phase-m-0-current-state.md
Normal file
125
development-doc/plan/memory-update/phase-m-0-current-state.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Phase M.0:Memory 现状与目标
|
||||
|
||||
日期:2026-04-04
|
||||
状态:已完成
|
||||
升级方向:拟人化记忆系统
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
本文件用于统一背景认知,明确:
|
||||
|
||||
- Jarvis 当前记忆系统处于什么水平
|
||||
- 主要短板是什么
|
||||
- 为什么要升级
|
||||
- 升级后的目标形态是什么
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前 Jarvis Memory 架构
|
||||
|
||||
### 2.1 核心流程
|
||||
|
||||
```
|
||||
用户对话 → MemoryService → Mem0 (facts/preferences/goals)
|
||||
→ BrainService → BrainMemory
|
||||
→ SQLite (memory_summaries)
|
||||
```
|
||||
|
||||
### 2.2 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/app/services/memory_service.py` | 三层记忆管理 |
|
||||
| `backend/app/services/brain_service.py` | Brain 学习与回忆 |
|
||||
| `backend/app/models/memory.py` | MemorySummary, UserMemory 模型 |
|
||||
| `backend/app/models/brain.py` | BrainEvent, BrainMemory, BrainTag 模型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前能力矩阵
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 短期记忆 | ✅ | messages 表存储当前会话 |
|
||||
| 中期记忆 | ✅ | memory_summaries 跨会话摘要 |
|
||||
| 长期记忆 | ✅ | Mem0 facts/preferences/goals |
|
||||
| 记忆检索 | ✅ | Mem0 语义搜索 + SQLite LIKE |
|
||||
| 记忆重要性 | ❌ | 无重要性评分 |
|
||||
| 频率追踪 | ❌ | 无频率统计 |
|
||||
| 遗忘机制 | ❌ | 存了就不删 |
|
||||
| 主动提醒 | ❌ | 完全被动响应 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前短板
|
||||
|
||||
| 短板 | 严重程度 | 影响 |
|
||||
|------|----------|------|
|
||||
| 无重要性评分 | 🔴 高 | 无法区分重要/不重要记忆 |
|
||||
| 无频率追踪 | 🔴 高 | 反复出现的痛点无法识别 |
|
||||
| 无遗忘机制 | 🟡 中 | 存储无限增长,冷门知识占空间 |
|
||||
| 无主动提醒 | 🔴 高 | 用户不问就不说,不像助理 |
|
||||
| 无情绪感知 | 🟡 中 | 无法区分用户急/重要的表达 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 目标架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 用户对话输入 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ Importance Scorer │ ← M.1 新增
|
||||
│ - 频率 (你提几次了) │
|
||||
│ - 情绪 (急/重要/困扰) │
|
||||
│ - 影响面 (关联多少) │
|
||||
│ - 时间 (最近/当时) │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ 高优先级 │ │ 中优先级 │ │ 低优先级 │
|
||||
│ (主动提醒)│ │ (记住) │ │ (归档) │
|
||||
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
形成观点 选择性回忆 自然遗忘
|
||||
主动提醒 需要时召回 (降权归档)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 VCPToolBox 的关系
|
||||
|
||||
| VCPToolBox 能力 | 本升级是否借鉴 | 理由 |
|
||||
|-----------------|---------------|------|
|
||||
| TagMemo 向量检索 | ❌ 不借鉴 | 你需要的是「记住什么」,不是「检索更准」 |
|
||||
| LIF 脉冲扩散 | ❌ 不借鉴 | 同上 |
|
||||
| EPA 语义分析 | ❌ 不借鉴 | 同上 |
|
||||
| 频率追踪 | ✅ 参考 | 这是你核心需要的,VCPToolBox 没有,自己设计 |
|
||||
|
||||
**结论:VCPToolBox 解决的是「知识管理」,本升级解决的是「记忆价值判断」,是两个不同的问题。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 升级后的直观改变
|
||||
|
||||
| 现在 | 升级后 |
|
||||
|------|--------|
|
||||
| 问 3 次同一个问题,Jarvis 每次都当新问题 | 第 2 次就记住,第 3 次能说"你之前问过..." |
|
||||
| 很少用的知识自动沉底 | 低频知识自动归档,需要时能恢复 |
|
||||
| 你问"明天干嘛" → 只能看日程 | 主动说"你昨天说想换工作,提醒你查一下 JD" |
|
||||
| 告诉 Jarvis 的事可能下次就忘了 | 重要的事主动记,冷门的事知道就行 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 本阶段产出要求
|
||||
|
||||
- [x] 团队对 Jarvis 当前记忆问题和目标方向达成一致
|
||||
- [x] 明确了与 VCPToolBox 的关系(不借鉴 VCPToolBox)
|
||||
- [x] 后续 phase 文档能够在这个认知基础上展开
|
||||
@@ -0,0 +1,219 @@
|
||||
# Phase M.1:重要性评分系统
|
||||
|
||||
日期:2026-04-04
|
||||
状态:规划中
|
||||
依赖:无
|
||||
工作量:4 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
建立记忆重要性评分体系,让 Jarvis 知道「什么对你重要」。
|
||||
|
||||
核心问题:
|
||||
- 你提了 3 次同一个问题 → 这是你的痛点,应该深入解决
|
||||
- 你说「急」「很重要」「困扰我」 → 情绪标记,应该优先级高
|
||||
- 这个话题关联了多少其他话题 → 影响面越大越重要
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ImportanceScorer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ calculate_score(memory) → importance_score (0.0-1.0) │
|
||||
│ │
|
||||
│ 评分维度: │
|
||||
│ - frequency_score (频率) × 0.35 │
|
||||
│ - recency_score (时效) × 0.20 │
|
||||
│ - emotion_score (情绪) × 0.25 │
|
||||
│ - impact_score (影响面) × 0.20 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 评分维度详解
|
||||
|
||||
### 3.1 Frequency Score (频率)
|
||||
|
||||
```python
|
||||
frequency_score = min(1.0, recall_count / FREQUENCY_THRESHOLD)
|
||||
# FREQUENCY_THRESHOLD = 3 # 提 3 次算高频
|
||||
|
||||
# 同时考虑时间衰减
|
||||
time_decay = exp(-days_since_last_recall / HALF_LIFE_DAYS)
|
||||
# HALF_LIFE_DAYS = 7 # 7 天减半
|
||||
```
|
||||
|
||||
### 3.2 Recency Score (时效性)
|
||||
|
||||
```python
|
||||
recency_score = exp(-days_since_creation / RECENCY_HALF_LIFE)
|
||||
# RECENCY_HALF_LIFE = 30 # 30 天减半
|
||||
|
||||
# 但重要事件例外:即使久远也保持高时效
|
||||
if is_emotion_tagged:
|
||||
recency_score = max(recency_score, 0.7)
|
||||
```
|
||||
|
||||
### 3.3 Emotion Score (情绪)
|
||||
|
||||
```python
|
||||
EMOTION_KEYWORDS = {
|
||||
"急": 1.0,
|
||||
"很重要": 0.9,
|
||||
"困扰": 0.8,
|
||||
"担心": 0.7,
|
||||
"想解决": 0.6,
|
||||
"无所谓": 0.1,
|
||||
}
|
||||
|
||||
emotion_score = max([EMOTION_KEYWORDS.get(kw, 0)
|
||||
for kw in extracted_emotions], default=0.0)
|
||||
```
|
||||
|
||||
### 3.4 Impact Score (影响面)
|
||||
|
||||
```python
|
||||
# 关联多少其他记忆/话题
|
||||
impact_score = min(1.0, associated_memory_count / IMPACT_THRESHOLD)
|
||||
# IMPACT_THRESHOLD = 5 # 关联 5 个算满
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心文件
|
||||
|
||||
### 4.1 新增文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `services/memory/frequency_tracker.py` | 频率追踪器 |
|
||||
| `services/memory/emotion_analyzer.py` | 情绪分析器 |
|
||||
| `services/memory/impact_evaluator.py` | 影响面评估器 |
|
||||
| `services/memory/importance_scorer.py` | 综合评分器 |
|
||||
|
||||
### 4.2 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `models/memory.py` | 增加 frequency_count, last_recalled_at, emotion_tags 字段 |
|
||||
| `services/memory_service.py` | 集成 ImportanceScorer |
|
||||
|
||||
---
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 ImportanceScorer
|
||||
|
||||
```python
|
||||
class ImportanceScorer:
|
||||
def calculate_score(self, memory: UserMemory) -> float:
|
||||
"""返回 0.0-1.0 的重要性分数"""
|
||||
|
||||
def get_importance_level(self, memory: UserMemory) -> Literal["high", "medium", "low"]:
|
||||
"""返回重要性等级"""
|
||||
|
||||
def should_escalate(self, memory: UserMemory) -> bool:
|
||||
"""是否应该升级为高优先级记忆"""
|
||||
```
|
||||
|
||||
### 5.2 集成到 MemoryService
|
||||
|
||||
```python
|
||||
class MemoryService:
|
||||
async def add_memory(self, user_id: int, content: str, emotion_tags: list[str] = None):
|
||||
# 添加记忆时计算初始重要性
|
||||
importance = self.scorer.calculate_score(new_memory)
|
||||
|
||||
async def recall_memories(self, query: str, user_id: int, top_k: int = 5):
|
||||
# 检索时考虑重要性排序
|
||||
# 高重要性记忆排在前面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据模型变更
|
||||
|
||||
### 6.1 UserMemory 扩展
|
||||
|
||||
```python
|
||||
class UserMemory:
|
||||
# 现有字段...
|
||||
frequency_count: int = 0 # 被回忆次数
|
||||
last_recalled_at: DateTime = None # 上次被回忆时间
|
||||
emotion_tags: list[str] = [] # 情绪标签
|
||||
importance_score: float = 0.5 # 重要性分数
|
||||
importance_level: str = "medium" # high/medium/low
|
||||
associated_topics: list[str] = [] # 关联话题
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试设计
|
||||
|
||||
### 7.1 频率追踪测试
|
||||
|
||||
```python
|
||||
def test_frequency_increase():
|
||||
memory = create_memory(frequency_count=0)
|
||||
memory = tracker.increment(memory)
|
||||
assert memory.frequency_count == 1
|
||||
|
||||
def test_frequency_decay_over_time():
|
||||
# 7 天后频率应该衰减
|
||||
pass
|
||||
```
|
||||
|
||||
### 7.2 情绪分析测试
|
||||
|
||||
```python
|
||||
def test_emotion_extraction():
|
||||
text = "这个问题很急,急需解决"
|
||||
emotions = analyzer.extract(text)
|
||||
assert "急" in emotions
|
||||
|
||||
def test_emotion_scoring():
|
||||
score = scorer.calculate_emotion_score(["急", "很重要"])
|
||||
assert score >= 0.9
|
||||
```
|
||||
|
||||
### 7.3 综合评分测试
|
||||
|
||||
```python
|
||||
def test_importance_ranking():
|
||||
memories = [low_freq, high_freq, emotional]
|
||||
ranked = sorter.rank_by_importance(memories)
|
||||
assert ranked[0] == emotional # 情绪标签最重
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
| 标准 | 说明 |
|
||||
|------|------|
|
||||
| 频率追踪正常 | recall_count 每次召回 +1 |
|
||||
| 情绪识别准确 | 「急」「很重要」等能识别 |
|
||||
| 重要性分数正确 | 高频+情绪 = importance >= 0.8 |
|
||||
| 评分影响排序 | 高重要性记忆排在检索结果前面 |
|
||||
| 单元测试覆盖率 | > 80% |
|
||||
|
||||
---
|
||||
|
||||
## 9. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| FrequencyTracker | 0.5 天 |
|
||||
| EmotionAnalyzer | 0.5 天 |
|
||||
| ImpactEvaluator | 0.5 天 |
|
||||
| ImportanceScorer | 1 天 |
|
||||
| 模型变更 | 0.5 天 |
|
||||
| 集成到 MemoryService | 0.5 天 |
|
||||
| 测试 | 1 天 |
|
||||
| **合计** | **4 天** |
|
||||
@@ -0,0 +1,228 @@
|
||||
# Phase M.2:遗忘曲线系统
|
||||
|
||||
日期:2026-04-04
|
||||
状态:规划中
|
||||
依赖:M.1 (重要性评分)
|
||||
工作量:3 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现「选择性遗忘」机制,让 Jarvis 知道「什么可以忘记」。
|
||||
|
||||
核心问题:
|
||||
- 冷门知识不应该一直占存储
|
||||
- 但不能直接删,要有归档机制
|
||||
- 重要记忆应该被强化,不容易忘
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ForgettingCurve │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ decay_score = exp(-time_since_access / half_life) │
|
||||
│ │
|
||||
│ 遗忘策略: │
|
||||
│ - decay_score < 0.2 → 归档到 cold_storage │
|
||||
│ - decay_score < 0.5 → 降权,不参与主动提醒 │
|
||||
│ - importance_level=high → 半衰期延长 3x │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 艾宾浩斯遗忘曲线模型
|
||||
|
||||
### 3.1 基础遗忘曲线
|
||||
|
||||
```
|
||||
保留率
|
||||
100% |████████████
|
||||
80% |██████████
|
||||
60% |███████
|
||||
40% |█████
|
||||
20% |██
|
||||
0% |________________________ 时间
|
||||
1天 7天 30天 90天
|
||||
```
|
||||
|
||||
### 3.2 Jarvis 遗忘策略
|
||||
|
||||
```python
|
||||
# 基础半衰期:30 天
|
||||
BASE_HALF_LIFE_DAYS = 30
|
||||
|
||||
# 重要性影响半衰期
|
||||
if importance_level == "high":
|
||||
half_life = BASE_HALF_LIFE_DAYS * 3 # 90 天
|
||||
elif importance_level == "medium":
|
||||
half_life = BASE_HALF_LIFE_DAYS * 1 # 30 天
|
||||
else:
|
||||
half_life = BASE_HALF_LIFE_DAYS * 0.5 # 15 天
|
||||
|
||||
# 遗忘分数
|
||||
decay_score = exp(-days_since_access / half_life)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心文件
|
||||
|
||||
### 4.1 新增文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `services/memory/forgetting_curve.py` | 遗忘曲线计算 |
|
||||
| `services/memory/memory_decay.py` | 记忆衰减处理 |
|
||||
| `services/memory/reinforcement.py` | 记忆强化触发 |
|
||||
|
||||
### 4.2 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `models/memory.py` | 增加 decay_score, is_archived, last_accessed_at 字段 |
|
||||
| `services/memory_service.py` | 集成遗忘逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 ForgettingCurve
|
||||
|
||||
```python
|
||||
class ForgettingCurve:
|
||||
def calculate_decay(self, memory: UserMemory) -> float:
|
||||
"""返回 0.0-1.0 的保留分数"""
|
||||
|
||||
def should_archive(self, memory: UserMemory) -> bool:
|
||||
"""是否应该归档"""
|
||||
|
||||
def should_deprioritize(self, memory: UserMemory) -> bool:
|
||||
"""是否应该降权(不参与主动提醒)"""
|
||||
```
|
||||
|
||||
### 5.2 Reinforcement
|
||||
|
||||
```python
|
||||
class MemoryReinforcement:
|
||||
def trigger(self, memory_id: int) -> None:
|
||||
"""被召回时触发强化"""
|
||||
# 频率 +1
|
||||
# decay_score 重置
|
||||
|
||||
def auto_reinforce(self, user_id: int) -> None:
|
||||
"""定期自动强化高重要性记忆"""
|
||||
# 每周检查,对 high 级别记忆做轻量强化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 归档机制
|
||||
|
||||
### 6.1 热/冷存储分离
|
||||
|
||||
```python
|
||||
# 热存储:活跃记忆,参与检索和主动提醒
|
||||
HOT_MEMORIES = []
|
||||
|
||||
# 冷存储:归档记忆,不参与主动提醒,按需恢复
|
||||
COLD_MEMORIES = []
|
||||
```
|
||||
|
||||
### 6.2 归档恢复
|
||||
|
||||
```python
|
||||
async def restore_from_archive(self, memory_id: int) -> UserMemory:
|
||||
"""从归档恢复记忆"""
|
||||
# 恢复后 decay_score 重置为 0.8
|
||||
# 重新加入热存储
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 调度任务
|
||||
|
||||
### 7.1 每日遗忘检查
|
||||
|
||||
```python
|
||||
# 每天凌晨执行
|
||||
@scheduler.scheduled_task("cron", hour=3)
|
||||
async def daily_forgetting_check():
|
||||
"""每日遗忘检查"""
|
||||
# 1. 计算所有记忆的 decay_score
|
||||
# 2. 归档 decay < 0.2 的记忆
|
||||
# 3. 降权 decay < 0.5 的记忆
|
||||
# 4. 强化被召回的记忆
|
||||
```
|
||||
|
||||
### 7.2 每周自动强化
|
||||
|
||||
```python
|
||||
@scheduler.scheduled_task("cron", day_of_week="mon", hour=4)
|
||||
async def weekly_reinforcement():
|
||||
"""每周自动强化"""
|
||||
# 对 high 重要性记忆做轻量强化
|
||||
# frequency_count *= 1.1 (上限 10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试设计
|
||||
|
||||
### 8.1 遗忘曲线测试
|
||||
|
||||
```python
|
||||
def test_decay_after_30_days():
|
||||
memory = create_memory(last_accessed_at=days_ago(30))
|
||||
decay = curve.calculate_decay(memory)
|
||||
assert 0.4 < decay < 0.6 # 约 50% 保留
|
||||
|
||||
def test_high_importance_slower_decay():
|
||||
high = create_memory(importance_level="high", last_accessed_at=days_ago(30))
|
||||
low = create_memory(importance_level="low", last_accessed_at=days_ago(30))
|
||||
assert curve.calculate_decay(high) > curve.calculate_decay(low)
|
||||
```
|
||||
|
||||
### 8.2 归档测试
|
||||
|
||||
```python
|
||||
def test_archive_low_decay():
|
||||
memory = create_memory(decay_score=0.15)
|
||||
assert curve.should_archive(memory) == True
|
||||
|
||||
def test_restore_from_archive():
|
||||
memory = service.restore_from_archive(memory_id)
|
||||
assert memory.is_archived == False
|
||||
assert memory.decay_score > 0.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
| 标准 | 说明 |
|
||||
|------|------|
|
||||
| 遗忘曲线正确 | 30 天后 decay ≈ 0.5 |
|
||||
| 高重要性记忆衰减慢 | high 级别衰减速度是 low 的 1/6 |
|
||||
| 归档正常 | decay < 0.2 自动归档 |
|
||||
| 恢复正常 | 归档记忆可以恢复 |
|
||||
| 调度任务正常 | 每日检查、周强化执行 |
|
||||
| 单元测试覆盖率 | > 80% |
|
||||
|
||||
---
|
||||
|
||||
## 10. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| ForgettingCurve | 0.5 天 |
|
||||
| MemoryDecay | 0.5 天 |
|
||||
| Reinforcement | 0.5 天 |
|
||||
| 归档机制 | 0.5 天 |
|
||||
| 调度任务 | 0.5 天 |
|
||||
| 测试 | 0.5 天 |
|
||||
| **合计** | **3 天** |
|
||||
@@ -0,0 +1,360 @@
|
||||
# Phase M.3:主动提醒系统
|
||||
|
||||
日期:2026-04-04
|
||||
状态:规划中
|
||||
依赖:M.1 (重要性评分), M.2 (遗忘曲线)
|
||||
工作量:5 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
让 Jarvis 从「等用户问」变成「主动关心」。
|
||||
|
||||
核心问题:
|
||||
- Jarvis 知道用户关心什么(高重要性记忆)
|
||||
- Jarvis 知道用户最近做了什么(每日摘要)
|
||||
- Jarvis 主动提醒,而不是等用户问
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ProactiveReminderSystem │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ DailyDigest │ + │ Reminder │ + │ MemoryInformer│ │
|
||||
│ │ Generator │ │ Scheduler │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 每日摘要生成 │ │ 提醒调度 │ │ 主动提醒推送 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 每日摘要生成器 (DailyDigestGenerator)
|
||||
|
||||
### 3.1 输入来源
|
||||
|
||||
```
|
||||
今日输入:
|
||||
- 用户今日对话摘要 (memory_summaries)
|
||||
- 用户今日提到的重点 (high importance memories)
|
||||
- 用户今日创建的任务 (tasks)
|
||||
- 用户今日查阅的知识 (knowledge retrieval logs)
|
||||
```
|
||||
|
||||
### 3.2 输出格式
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-04-04",
|
||||
"summary": "今天你主要在处理工作问题,提到了换工作的想法",
|
||||
"key_points": [
|
||||
{"content": "想换工作", "importance": 0.9, "source": "conversation"},
|
||||
{"content": "项目 deadline 是周五", "importance": 0.8, "source": "task"}
|
||||
],
|
||||
"pending_questions": [
|
||||
{"content": "量子计算的问题还没完全理解", "importance": 0.6}
|
||||
],
|
||||
"suggestions": [
|
||||
{"text": "明天可以继续聊换工作的话题", "reason": "重要性高且今天没深入"},
|
||||
{"text": "量子计算的资料可以找找更通俗的解释", "reason": "还没理解"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 生成时机
|
||||
|
||||
```python
|
||||
# 每天晚上 10 点生成
|
||||
@scheduler.scheduled_task("cron", hour=22)
|
||||
async def generate_daily_digest():
|
||||
"""生成每日摘要"""
|
||||
digest = await generator.generate(user_id)
|
||||
await storage.save(digest)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 提醒调度器 (ReminderScheduler)
|
||||
|
||||
### 4.1 提醒类型
|
||||
|
||||
| 类型 | 触发条件 | 推送时机 |
|
||||
|------|---------|---------|
|
||||
| 后续提醒 | 用户说「回头提醒我」 | 指定时间 |
|
||||
| 关联提醒 | 提到的话题有关联记忆 | 下次对话时 |
|
||||
| 周期提醒 | 每周/每月固定事项 | 周期首日 |
|
||||
| 遗忘提醒 | 归档记忆被重新提到 | 恢复后提醒 |
|
||||
|
||||
### 4.2 调度逻辑
|
||||
|
||||
```python
|
||||
class ReminderScheduler:
|
||||
def schedule_reminder(self, user_id: int, reminder: Reminder):
|
||||
"""安排提醒"""
|
||||
|
||||
def get_due_reminders(self, user_id: int) -> list[Reminder]:
|
||||
"""获取到期的提醒"""
|
||||
|
||||
def snooze_reminder(self, reminder_id: int, minutes: int):
|
||||
"""推迟提醒"""
|
||||
```
|
||||
|
||||
### 4.3 提醒存储
|
||||
|
||||
```python
|
||||
class Reminder:
|
||||
id: int
|
||||
user_id: int
|
||||
content: str
|
||||
trigger_type: str # "time" / "context" / "periodic"
|
||||
trigger_at: DateTime
|
||||
context: dict # 关联的 memory_id 等
|
||||
status: str # "pending" / "sent" / "snoozed"
|
||||
created_at: DateTime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 主动记忆告知器 (ProactiveMemoryInformer)
|
||||
|
||||
### 5.1 触发条件
|
||||
|
||||
```python
|
||||
TRIGGERS = {
|
||||
"high_importance_topic": {
|
||||
"condition": "用户提到高重要性话题",
|
||||
"action": "提及关联记忆",
|
||||
"example": "你说想换工作,要不要看看之前收藏的 JD?"
|
||||
},
|
||||
"repeat_question": {
|
||||
"condition": "用户重复问某个问题",
|
||||
"action": "主动说之前回答过",
|
||||
"example": "你之前问过量子计算,我再给你解释一下?"
|
||||
},
|
||||
"forgotten_context": {
|
||||
"condition": "用户提到已归档的记忆",
|
||||
"action": "提示可以恢复",
|
||||
"example": "这个话题你一个月前聊过,要我恢复一下吗?"
|
||||
},
|
||||
"pending_goal": {
|
||||
"condition": "用户设了目标但没进展",
|
||||
"action": "温和提醒",
|
||||
"example": "你之前说想学 Python,有进展吗?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 告知时机
|
||||
|
||||
```python
|
||||
# 不是每次对话都告知,有概率控制
|
||||
INFORM_PROBABILITY = {
|
||||
"high_importance_topic": 0.8, # 高重要性话题 80% 主动提
|
||||
"repeat_question": 1.0, # 重复问题 100% 主动提
|
||||
"forgotten_context": 0.5, # 已归档话题 50% 提示
|
||||
"pending_goal": 0.3, # 待办目标 30% 温和提醒
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 告知风格
|
||||
|
||||
```python
|
||||
INFORM_STYLE = {
|
||||
"casual": "对了,你之前提到...",
|
||||
"gentle": "不知道你有没有注意到...",
|
||||
"helpful": "我记起你关心这个,要不看看..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心文件
|
||||
|
||||
### 6.1 新增文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `services/memory/daily_digest.py` | 每日摘要生成 |
|
||||
| `services/memory/reminder_scheduler.py` | 提醒调度 |
|
||||
| `services/memory/proactive_informer.py` | 主动告知 |
|
||||
| `services/memory/reminder_model.py` | 提醒数据模型 |
|
||||
|
||||
### 6.2 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `services/scheduler_service.py` | 集成主动提醒调度 |
|
||||
| `services/memory_service.py` | 集成 ProactiveInformer |
|
||||
| `routers/conversation.py` | 主动告知触发点 |
|
||||
|
||||
---
|
||||
|
||||
## 7. API 设计
|
||||
|
||||
### 7.1 DailyDigestGenerator
|
||||
|
||||
```python
|
||||
class DailyDigestGenerator:
|
||||
async def generate(self, user_id: int, date: date = None) -> DailyDigest:
|
||||
"""生成每日摘要"""
|
||||
|
||||
async def get_recent_digests(self, user_id: int, limit: int = 7) -> list[Digest]:
|
||||
"""获取最近 N 天的摘要"""
|
||||
```
|
||||
|
||||
### 7.2 ReminderScheduler
|
||||
|
||||
```python
|
||||
class ReminderScheduler:
|
||||
async def create_reminder(self, user_id: int, content: str, trigger_at: datetime):
|
||||
"""创建提醒"""
|
||||
|
||||
async def get_due_reminders(self, user_id: int) -> list[Reminder]:
|
||||
"""获取到期提醒"""
|
||||
|
||||
async def snooze(self, reminder_id: int, minutes: int):
|
||||
"""推迟提醒"""
|
||||
```
|
||||
|
||||
### 7.3 ProactiveInformer
|
||||
|
||||
```python
|
||||
class ProactiveInformer:
|
||||
def should_inform(self, user_id: int, trigger_type: str) -> bool:
|
||||
"""是否应该告知"""
|
||||
|
||||
def get_inform_message(self, user_id: int, trigger_type: str, context: dict) -> str:
|
||||
"""生成告知消息"""
|
||||
|
||||
async def check_and_inform(self, conversation_context: dict) -> str | None:
|
||||
"""检查并返回告知消息,无则返回 None"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端集成
|
||||
|
||||
### 8.1 每日摘要展示
|
||||
|
||||
```vue
|
||||
<!-- 每日摘要卡片 -->
|
||||
<div v-if="dailyDigest">
|
||||
<h3>今日摘要</h3>
|
||||
<p>{{ dailyDigest.summary }}</p>
|
||||
<div v-for="point in dailyDigest.keyPoints">
|
||||
- {{ point.content }}
|
||||
</div>
|
||||
<div v-if="dailyDigest.suggestions.length">
|
||||
<h4>建议</h4>
|
||||
<p>{{ dailyDigest.suggestions[0].text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 8.2 主动提醒推送
|
||||
|
||||
```vue
|
||||
<!-- 主动提醒弹窗 -->
|
||||
<div v-if="activeReminder" class="reminder-toast">
|
||||
<p>{{ activeReminder.content }}</p>
|
||||
<button @click="snooze">稍后</button>
|
||||
<button @click="dismiss">知道了</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试设计
|
||||
|
||||
### 9.1 每日摘要测试
|
||||
|
||||
```python
|
||||
async def test_digest_generation():
|
||||
digest = await generator.generate(user_id=1)
|
||||
assert digest.summary is not None
|
||||
assert len(digest.key_points) > 0
|
||||
|
||||
async def test_digest_includes_high_importance():
|
||||
# 高重要性记忆应该出现在摘要中
|
||||
pass
|
||||
```
|
||||
|
||||
### 9.2 提醒调度测试
|
||||
|
||||
```python
|
||||
async def test_schedule_reminder():
|
||||
reminder = await scheduler.create_reminder(
|
||||
user_id=1,
|
||||
content="检查邮件",
|
||||
trigger_at=datetime.now() + timedelta(hours=1)
|
||||
)
|
||||
assert reminder.status == "pending"
|
||||
|
||||
async def test_get_due_reminders():
|
||||
due = await scheduler.get_due_reminders(user_id=1)
|
||||
assert len(due) >= 0
|
||||
```
|
||||
|
||||
### 9.3 主动告知测试
|
||||
|
||||
```python
|
||||
def test_should_inform_probability():
|
||||
# 高重要性话题 80% 触发
|
||||
count = sum(informer.should_inform(1, "high_importance_topic")
|
||||
for _ in range(100))
|
||||
assert 70 < count < 90 # 允许一点随机波动
|
||||
|
||||
def test_repeat_question_always_informs():
|
||||
# 重复问题 100% 触发
|
||||
assert informer.should_inform(1, "repeat_question") == True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
| 标准 | 说明 |
|
||||
|------|------|
|
||||
| 每日摘要生成正常 | 22:00 自动生成 |
|
||||
| 提醒创建正常 | 用户可创建提醒 |
|
||||
| 提醒到期触发 | 定时推送 |
|
||||
| 主动告知概率正确 | 按配置的概率触发 |
|
||||
| 告知消息自然 | 像人说话,不生硬 |
|
||||
| 用户可控制 | 可以关闭主动提醒 |
|
||||
| 单元测试覆盖率 | > 80% |
|
||||
|
||||
---
|
||||
|
||||
## 11. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| DailyDigestGenerator | 1.5 天 |
|
||||
| ReminderScheduler | 1.5 天 |
|
||||
| ProactiveInformer | 1 天 |
|
||||
| 前端摘要展示 | 0.5 天 |
|
||||
| 前端提醒推送 | 0.5 天 |
|
||||
| 测试 | 1 天 |
|
||||
| **合计** | **6 天** |
|
||||
|
||||
---
|
||||
|
||||
## 12. 与现有系统的关系
|
||||
|
||||
```
|
||||
现有 SchedulerService
|
||||
│
|
||||
├── 凌晨任务重建 → 现有功能
|
||||
├── 每日摘要生成 → M.3 新增
|
||||
└── 提醒检查 → M.3 新增
|
||||
```
|
||||
|
||||
提醒系统独立于现有任务系统,但可以复用调度基础设施。
|
||||
157
development-doc/plan/rag-update/README.md
Normal file
157
development-doc/plan/rag-update/README.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Jarvis RAG 升级计划索引
|
||||
|
||||
本目录用于存放 Jarvis RAG 系统的分阶段升级规划文档。
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、实施顺序 |
|
||||
| `phase-r-0-current-state.md` | 当前现状、问题、目标架构、VCPToolBox 借鉴 |
|
||||
| `phase-r-1-token-chunking.md` | Token 感知分块优化 |
|
||||
| `phase-r-2-multi-index.md` | 多索引架构 |
|
||||
| `phase-r-3-dynamic-weight.md` | 动态权重增强 |
|
||||
| `phase-r-4-advanced.md` | 高级特性(可选) |
|
||||
| `checklist.md` | 执行清单 |
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先读 `phase-r-0-current-state.md`
|
||||
2. 再按顺序阅读 phase r-1 ~ r-4
|
||||
3. 实施时严格按阶段推进,R.4 为可选
|
||||
4. 参考 `checklist.md` 进行任务追踪
|
||||
|
||||
---
|
||||
|
||||
## 总体升级原则
|
||||
|
||||
1. **Token 精确控制** - 使用 tiktoken 精确计数
|
||||
2. **多索引分层** - 按知识类型/重要性分离
|
||||
3. **动态适配** - 根据查询特性动态调整检索策略
|
||||
4. **测试优先** - 所有升级都要配套测试
|
||||
5. **可独立推进** - Phase R 可与 Agent Phase 1-5 并行
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览图
|
||||
|
||||
```
|
||||
R.0 ──────────────────────────────────────────────────────────────┐
|
||||
│ 现状与目标 │
|
||||
│ - 当前架构分析 │
|
||||
│ - 短板识别 │
|
||||
│ - VCPToolBox TagMemo V6 借鉴 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.1 ──────────────────────────────────────────────────────────────┐
|
||||
│ Token 感知分块优化 │
|
||||
│ - tiktoken 集成 │
|
||||
│ - 智能断句 │
|
||||
│ - 重叠分块 (10% overlap) │
|
||||
│ │
|
||||
│ 核心文件: services/chunker.py │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.2 ──────────────────────────────────────────────────────────────┐
|
||||
│ 多索引架构 │
|
||||
│ - Collection 分离策略 │
|
||||
│ - 懒加载 + LRU TTL │
|
||||
│ - 重要性感知检索 │
|
||||
│ │
|
||||
│ 核心文件: services/multi_index.py │
|
||||
│ 依赖: R.1 │
|
||||
│ 工作量: 4 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.3 ──────────────────────────────────────────────────────────────┐
|
||||
│ 动态权重增强 │
|
||||
│ - QueryAnalyzer │
|
||||
│ - DynamicReranker │
|
||||
│ - CoreTagAwareSearch │
|
||||
│ │
|
||||
│ 核心文件: services/query_analyzer.py, │
|
||||
│ services/dynamic_reranker.py, │
|
||||
│ services/core_tag_search.py │
|
||||
│ 依赖: R.1 │
|
||||
│ 工作量: 4.5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.4 ──────────────────────────────────────────────────────────────┐
|
||||
│ 高级特性 (可选) │
|
||||
│ - 语义去重 │
|
||||
│ - 语义分桶 │
|
||||
│ - EPA 分析设计 │
|
||||
│ │
|
||||
│ 状态: 可选 │
|
||||
│ 工作量: 4.5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VCPToolBox TagMemo V6 核心借鉴
|
||||
|
||||
| 借鉴点 | 实现位置 | 难度 |
|
||||
|--------|---------|------|
|
||||
| Token 感知分块(85%+10% 重叠) | R.1 | 🟢 低 |
|
||||
| 多索引架构 | R.2 | 🟡 中 |
|
||||
| 懒加载 + LRU | R.2 | 🟡 中 |
|
||||
| TagBoost 动态权重 | R.3 | 🟡 中 |
|
||||
| 核心标签系统(1.33x 加权) | R.3 | 🟡 中 |
|
||||
| LIF 脉冲扩散 | R.4 | 🔴 高 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
R.0 → R.1 → R.2 → R.3 → (R.4 可选)
|
||||
│ │ │ │
|
||||
│ │ │ └── 语义去重/分桶/PCA
|
||||
│ │ └── 多索引 + 懒加载
|
||||
│ └── Token感知分块
|
||||
└── 现状与目标
|
||||
```
|
||||
|
||||
**注意:** R.1 是基础,R.2 和 R.3 都依赖 R.1;R.4 可选。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更追踪
|
||||
|
||||
| Phase | 新增文件 | 修改文件 |
|
||||
|-------|---------|---------|
|
||||
| R.1 | `services/chunker.py`, `tests/test_chunker.py` | `services/document_service.py` |
|
||||
| R.2 | `services/multi_index.py`, `tests/test_multi_index.py` | `services/knowledge_service.py`, `models/document.py` |
|
||||
| R.3 | `services/query_analyzer.py`, `services/dynamic_reranker.py`, `services/core_tag_search.py`, `tests/test_dynamic_reranker.py` | `services/knowledge_service.py`, `models/document.py` |
|
||||
| R.4 | `services/deduplicator.py`, `services/semantic_bucket.py` (可选) | - |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 1-5 的关系
|
||||
|
||||
| Agent Phase | RAG 协作内容 |
|
||||
|-------------|-------------|
|
||||
| Phase 1 | Task Schema 追踪 RAG 任务 |
|
||||
| Phase 2 | RAG 任务可分解给 Librarian Agent |
|
||||
| Phase 3 | 支持多索引动态选择 |
|
||||
| Phase 4 | RAG 检索过程可视化 |
|
||||
| Phase 5 | EPA 分析、语义分桶 |
|
||||
| **Phase R** | **独立 RAG 升级路径,可与 Phase 1-5 并行推进** |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 注意事项 | 说明 |
|
||||
|---------|------|
|
||||
| R.1 是基础 | R.2 和 R.3 都依赖 R.1 的分块优化 |
|
||||
| Token 精确计数 | 使用 tiktoken,多版本验证 |
|
||||
| 索引分离 | 提供统一检索接口,隐藏内部逻辑 |
|
||||
| 动态权重 | 提供配置项,允许用户调整 |
|
||||
| EPA 高复杂度 | Phase R.4 可选,暂不实现 |
|
||||
327
development-doc/plan/rag-update/checklist.md
Normal file
327
development-doc/plan/rag-update/checklist.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Jarvis RAG 升级执行清单
|
||||
|
||||
日期:2026-04-03
|
||||
状态:执行清单
|
||||
借鉴来源:VCPToolBox TagMemo V6 架构
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 完成前使用 `- [ ]`
|
||||
- 完成后改成 `- [x]`
|
||||
- Day R.2 默认依赖 Day R.1 的分块优化完成后再推进
|
||||
- Day R.3 默认依赖 Day R.1 的分块优化完成后再推进
|
||||
- Day R.4 为可选特性
|
||||
|
||||
---
|
||||
|
||||
## Day R.1:Token 感知分块优化(3天)
|
||||
|
||||
Day R.1 目标:解决跨块边界信息丢失问题,实现精确的 token 计数和重叠分块。
|
||||
|
||||
### Task R.1.1:集成 tiktoken
|
||||
|
||||
- [ ] 安装 tiktoken 依赖
|
||||
```bash
|
||||
uv add tiktoken
|
||||
```
|
||||
|
||||
- [ ] 新增 `backend/app/services/chunker.py`
|
||||
实现 `TokenAwareChunker` 类,支持 85% 安全边界
|
||||
|
||||
- [ ] 实现 `count_tokens()` 方法
|
||||
|
||||
### Task R.1.2:实现智能断句
|
||||
|
||||
- [ ] 实现 `find_best_breakpoint()` 函数
|
||||
在断点处(标点/空白)智能断开
|
||||
|
||||
- [ ] 实现 `_force_split_long_text()` 方法
|
||||
处理超长句子强制分割
|
||||
|
||||
### Task R.1.3:实现重叠分块
|
||||
|
||||
- [ ] 实现 `chunk_with_overlap()` 方法
|
||||
10% token 重叠,保证上下文连续性
|
||||
|
||||
- [ ] 实现 `_create_overlap()` 方法
|
||||
创建重叠部分
|
||||
|
||||
### Task R.1.4:集成到 DocumentService
|
||||
|
||||
- [ ] 修改 `backend/app/services/document_service.py`
|
||||
集成新的 TokenAwareChunker
|
||||
|
||||
- [ ] 替换原有的 `_build_chunks()` 方法
|
||||
|
||||
### Task R.1.5:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_chunker.py`
|
||||
|
||||
- [ ] 测试 Token 计数准确性
|
||||
|
||||
- [ ] 测试智能断句
|
||||
|
||||
- [ ] 测试重叠分块
|
||||
|
||||
### Day R.1 验收
|
||||
|
||||
- [ ] tiktoken 正确集成,token 计数误差 < 1%
|
||||
- [ ] 超长句子不在词汇中间断开
|
||||
- [ ] 重叠分块保证上下文连续性
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
- [ ] 文档上传→分块→检索 集成测试通过
|
||||
|
||||
---
|
||||
|
||||
## Day R.2:多索引架构(4天)
|
||||
|
||||
Day R.2 目标:按知识类型/重要性分层,支持懒加载和 LRU 淘汰。
|
||||
|
||||
### Task R.2.1:设计 Collection 分离策略
|
||||
|
||||
- [ ] 新增 `backend/app/services/multi_index.py`
|
||||
|
||||
- [ ] 定义 `MultiIndexManager` 类
|
||||
|
||||
- [ ] 实现 `INDEX_STRATEGIES` 配置
|
||||
- default: 通用文档
|
||||
- important: 重要文档
|
||||
- code: 代码片段
|
||||
- meeting: 会议记录
|
||||
|
||||
- [ ] 实现 `get_collection()` 方法
|
||||
|
||||
### Task R.2.2:实现懒加载 + LRU TTL
|
||||
|
||||
- [ ] 实现 `LazyIndexLoader` 类
|
||||
|
||||
- [ ] 实现 `get_or_load()` 方法
|
||||
|
||||
- [ ] 实现 `sweep()` 方法
|
||||
2小时 TTL 淘汰机制
|
||||
|
||||
### Task R.2.3:实现重要性感知检索
|
||||
|
||||
- [ ] 实现 `retrieve_with_importance()` 方法
|
||||
|
||||
- [ ] important 索引加权 1.2x
|
||||
|
||||
### Task R.2.4:修改 Document 模型
|
||||
|
||||
- [ ] 修改 `backend/app/models/document.py`
|
||||
|
||||
- [ ] 增加 `importance` 字段(Float, default=0.5)
|
||||
|
||||
### Task R.2.5:集成到 KnowledgeService
|
||||
|
||||
- [ ] 修改 `backend/app/services/knowledge_service.py`
|
||||
|
||||
- [ ] 集成 MultiIndexManager
|
||||
|
||||
- [ ] 集成 LazyIndexLoader
|
||||
|
||||
- [ ] 根据 importance 选择索引
|
||||
|
||||
### Task R.2.6:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_multi_index.py`
|
||||
|
||||
- [ ] 测试多 Collection 创建
|
||||
|
||||
- [ ] 测试懒加载
|
||||
|
||||
- [ ] 测试 TTL 淘汰
|
||||
|
||||
### Day R.2 验收
|
||||
|
||||
- [ ] 多 Collection 创建成功
|
||||
- [ ] 懒加载索引生效
|
||||
- [ ] TTL 淘汰机制工作
|
||||
- [ ] 重要性感知检索加权生效
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
## Day R.3:动态权重增强(4.5天)
|
||||
|
||||
Day R.3 目标:根据查询特性动态调整检索策略,支持核心标签加权。
|
||||
|
||||
### Task R.3.1:实现查询特性分析
|
||||
|
||||
- [ ] 新增 `backend/app/services/query_analyzer.py`
|
||||
|
||||
- [ ] 定义 `QueryProfile` 数据类
|
||||
|
||||
- [ ] 实现 `QueryAnalyzer` 类
|
||||
|
||||
- [ ] 实现查询类型检测
|
||||
- 代码相关
|
||||
- 表格相关
|
||||
- 对话式
|
||||
|
||||
- [ ] 实现 `_calc_logic_depth()` 方法
|
||||
|
||||
### Task R.3.2:实现动态 Reranker
|
||||
|
||||
- [ ] 新增 `backend/app/services/dynamic_reranker.py`
|
||||
|
||||
- [ ] 实现 `DynamicReranker` 类
|
||||
|
||||
- [ ] 实现 `_get_weights()` 方法
|
||||
- 代码查询:关键词权重高
|
||||
- 表格查询:标题权重高
|
||||
- 对话式:语义权重高
|
||||
|
||||
- [ ] 实现 `_calc_beta()` 方法
|
||||
|
||||
- [ ] 实现 `rerank()` 方法
|
||||
|
||||
### Task R.3.3:实现核心标签系统
|
||||
|
||||
- [ ] 新增 `backend/app/services/core_tag_search.py`
|
||||
|
||||
- [ ] 实现 `CoreTagAwareSearch` 类
|
||||
|
||||
- [ ] 实现 `CORE_BOOST_FACTOR = 1.33`
|
||||
|
||||
- [ ] 实现 `search()` 方法
|
||||
|
||||
### Task R.3.4:修改 DocumentChunk 模型
|
||||
|
||||
- [ ] 修改 `backend/app/models/document.py`
|
||||
|
||||
- [ ] 增加 `tags` 字段(JSON, default=list)
|
||||
|
||||
- [ ] 增加 `is_core` 字段(Boolean, default=False)
|
||||
|
||||
### Task R.3.5:集成到 KnowledgeService
|
||||
|
||||
- [ ] 修改 `backend/app/services/knowledge_service.py`
|
||||
|
||||
- [ ] 集成 QueryAnalyzer
|
||||
|
||||
- [ ] 集成 DynamicReranker
|
||||
|
||||
- [ ] 集成 CoreTagAwareSearch
|
||||
|
||||
- [ ] 修改 `retrieve()` 方法支持动态权重
|
||||
|
||||
### Task R.3.6:补测试
|
||||
|
||||
- [ ] 新增 `backend/tests/services/test_dynamic_reranker.py`
|
||||
|
||||
- [ ] 测试查询特性分析
|
||||
|
||||
- [ ] 测试动态权重调整
|
||||
|
||||
- [ ] 测试核心标签加权
|
||||
|
||||
### Day R.3 验收
|
||||
|
||||
- [ ] 查询特性分析准确(代码/表格/对话式识别)
|
||||
- [ ] 动态权重根据查询类型调整
|
||||
- [ ] 核心标签检索加权 1.33x
|
||||
- [ ] Rerank 集成测试通过
|
||||
|
||||
---
|
||||
|
||||
## Day R.4:高级特性(可选)(4.5天)
|
||||
|
||||
Day R.4 目标:探索更高级的 RAG 增强技术。
|
||||
|
||||
### Task R.4.1:语义去重
|
||||
|
||||
- [ ] 新增 `backend/app/services/deduplicator.py`
|
||||
|
||||
- [ ] 实现 `SemanticDeduplicator` 类
|
||||
|
||||
- [ ] 实现 `_cosine_similarity()` 方法
|
||||
|
||||
- [ ] 实现 `deduplicate()` 方法
|
||||
|
||||
### Task R.4.2:语义分桶(可选)
|
||||
|
||||
- [ ] 新增 `backend/app/services/semantic_bucket.py`
|
||||
|
||||
- [ ] 实现 `SemanticBucketing` 类
|
||||
|
||||
- [ ] 实现 `bucket_by_topic()` 方法
|
||||
|
||||
### Task R.4.3:EPA 分析设计(可选探索)
|
||||
|
||||
- [ ] 设计 EPA 模块架构
|
||||
|
||||
- [ ] 定义 EPA 接口
|
||||
|
||||
- [ ] 实现残差金字塔算法(伪代码)
|
||||
|
||||
### Day R.4 验收(可选)
|
||||
|
||||
- [ ] 语义去重测试通过
|
||||
- [ ] 语义分桶原型完成(可选)
|
||||
- [ ] EPA 分析方案设计完成(可选实现)
|
||||
|
||||
---
|
||||
|
||||
## 总验收清单
|
||||
|
||||
### Phase R.1-R.3 必须完成
|
||||
|
||||
- [ ] Token 感知分块正常工作
|
||||
- [ ] 多索引架构正常工作
|
||||
- [ ] 动态权重增强正常工作
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
- [ ] 集成测试通过
|
||||
- [ ] 原有检索功能无回退
|
||||
|
||||
### Phase R.4 可选完成
|
||||
|
||||
- [ ] 语义去重正常工作
|
||||
- [ ] 语义分桶正常工作(可选)
|
||||
- [ ] EPA 设计文档完成(可选)
|
||||
|
||||
---
|
||||
|
||||
## 总工作量估算
|
||||
|
||||
| Phase | 工作量 |
|
||||
|-------|--------|
|
||||
| R.1 Token 感知分块 | 3 天 |
|
||||
| R.2 多索引架构 | 4 天 |
|
||||
| R.3 动态权重增强 | 4.5 天 |
|
||||
| R.4 高级特性(可选) | 4.5 天 |
|
||||
| **R.1-R.3 必须** | **11.5 天** |
|
||||
| **R.1-R.4 含可选** | **16 天** |
|
||||
|
||||
---
|
||||
|
||||
## 产出清单
|
||||
|
||||
| 产出 | 对应 Phase |
|
||||
|------|-----------|
|
||||
| `services/chunker.py` | R.1 |
|
||||
| `services/multi_index.py` | R.2 |
|
||||
| `services/query_analyzer.py` | R.3 |
|
||||
| `services/dynamic_reranker.py` | R.3 |
|
||||
| `services/core_tag_search.py` | R.3 |
|
||||
| `services/deduplicator.py` | R.4 |
|
||||
| `services/semantic_bucket.py` | R.4(可选) |
|
||||
| `models/document.py` 更新 | R.2, R.3 |
|
||||
| 单元测试 > 80% | R.1, R.2, R.3 |
|
||||
| 集成测试通过 | R.1, R.2, R.3 |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 关系
|
||||
|
||||
| Agent Phase | RAG 协作内容 |
|
||||
|-------------|-------------|
|
||||
| Phase 1 | Task Schema 追踪 RAG 任务 |
|
||||
| Phase 2 | RAG 任务可分解给 Librarian Agent |
|
||||
| Phase 3 | 支持多索引动态选择 |
|
||||
| Phase 4 | RAG 检索过程可视化 |
|
||||
| Phase 5 | EPA 分析、语义分桶 |
|
||||
|
||||
**Phase R 可与 Agent Phase 1-5 并行推进。**
|
||||
156
development-doc/plan/rag-update/phase-r-0-current-state.md
Normal file
156
development-doc/plan/rag-update/phase-r-0-current-state.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Phase R.0:RAG 现状与目标
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已完成
|
||||
借鉴来源:VCPToolBox TagMemo V6 架构
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
本文件用于统一背景认知,明确:
|
||||
|
||||
- Jarvis 当前 RAG 架构处于什么水平
|
||||
- 主要短板是什么
|
||||
- 为什么要升级
|
||||
- 升级后的目标形态是什么
|
||||
- VCPToolBox 给我们什么启发
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前 Jarvis RAG 架构
|
||||
|
||||
### 2.1 核心流程
|
||||
|
||||
```
|
||||
用户上传文档 → DocumentService (解析/分块) → ChromaDB (向量存储) → KnowledgeService (检索)
|
||||
```
|
||||
|
||||
### 2.2 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/app/services/document_service.py` | 文档上传/解析/分块 |
|
||||
| `backend/app/services/knowledge_service.py` | ChromaDB 向量检索/混合检索 |
|
||||
| `backend/app/models/document.py` | Document/DocumentChunk 数据模型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前能力矩阵
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 多格式文档解析 | ✅ | PDF/MD/TXT/DOCX/CSV/XLSX |
|
||||
| 结构化分块 | ✅ | 基于标题层级、表格、段落 |
|
||||
| 向量检索 | ✅ | ChromaDB 语义相似度 |
|
||||
| 关键词检索 | ✅ | SQL LIKE |
|
||||
| 混合检索 | ✅ | 向量 + 关键词加权 |
|
||||
| Rerank | ✅ | 语义分×0.7 + 关键词×0.2 + 标题×0.1 |
|
||||
| 上下文丰富 | ✅ | 自动获取前/后 chunk |
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前短板
|
||||
|
||||
| 短板 | 严重程度 | 影响 |
|
||||
|------|----------|------|
|
||||
| 无重叠分块 | 🟡 中 | 跨块边界信息丢失 |
|
||||
| 单索引架构 | 🟡 中 | 无法按知识类型/重要性分层 |
|
||||
| 无动态权重 | 🟡 中 | 检索策略静态,不适配查询类型 |
|
||||
| 无 Tag/标签系统 | 🟡 中 | 无法利用语义标签增强检索 |
|
||||
| 无懒加载机制 | 🟢 低 | 大量文档时内存占用高 |
|
||||
| 无遗忘机制 | 🟢 低 | 存储无限增长 |
|
||||
|
||||
---
|
||||
|
||||
## 5. VCPToolBox TagMemo V6 核心借鉴
|
||||
|
||||
### 5.1 核心架构
|
||||
|
||||
```
|
||||
日记文件变化 → TextChunker(Token感知分块85%+10%重叠)
|
||||
→ EmbeddingUtils(并发批量向量化)
|
||||
→ SQLite(元数据) + VexusIndex(Rust HNSW向量索引)
|
||||
```
|
||||
|
||||
### 5.2 TagMemo V6 检索流程
|
||||
|
||||
```
|
||||
Query → EPA分析(逻辑深度L/共振R) → 残差金字塔 → TagBoost(β动态权重)
|
||||
→ LIF脉冲扩散(2跳) → 向量融合 → VexusIndex搜索
|
||||
```
|
||||
|
||||
### 5.3 核心模块
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| TextChunker | Token 感知分块,85% 安全边界 + 10% 重叠 |
|
||||
| EPA | 语义空间投影分析,识别逻辑深度和跨域共振 |
|
||||
| Residual Pyramid | 残差金字塔,多级剥离捕获微弱信号 |
|
||||
| TagBoost | 动态权重增强,根据查询特性调整 |
|
||||
| LIF Spike | 脉冲扩散,2跳拓扑联想 |
|
||||
| VexusIndex | Rust HNSW 向量索引,高性能检索 |
|
||||
|
||||
### 5.4 关键设计理念
|
||||
|
||||
1. **TagMemo 不是搜索引擎,是记忆联想引擎** - 模拟人类大脑的感知→编码→巩固→检索→重构
|
||||
2. **动态适配** - 根据查询意图动态调整检索策略
|
||||
3. **拓扑涌现** - 基于共现矩阵的脉冲扩散,产生非直观联想
|
||||
|
||||
---
|
||||
|
||||
## 6. 目标架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User Query │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ Query Analyzer │ ← R.3 新增
|
||||
│ (查询特性分析) │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────┐ ┌──────────────┐
|
||||
│ Default │ │ Important │ │ Code/Meeting │
|
||||
│ Collection│ │ Collection │ │ Collections │
|
||||
└────┬─────┘ └─────┬─────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└──────────────────┼─────────────────┘
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Dynamic Reranker │ ← R.3 新增
|
||||
│ (Core Tag Boost + 动态权重)│
|
||||
└───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Search Result │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 借鉴点映射
|
||||
|
||||
| VCPToolBox 借鉴点 | Jarvis 实现位置 | 优先级 |
|
||||
|-------------------|---------------|--------|
|
||||
| Token 感知分块(85%+10% 重叠) | `services/chunker.py` | 🟢 高 |
|
||||
| 多索引架构 | `services/multi_index.py` | 🟡 中 |
|
||||
| 懒加载 + LRU TTL | `services/multi_index.py` | 🟡 中 |
|
||||
| TagBoost 动态权重 | `services/dynamic_reranker.py` | 🟡 中 |
|
||||
| 核心标签系统(1.33x 加权) | `services/core_tag_search.py` | 🟡 中 |
|
||||
| 语义去重 | `services/deduplicator.py` | 🔴 低 |
|
||||
| 语义分桶 | `services/semantic_bucket.py` | 🔴 低 |
|
||||
| EPA 分析 | - | 🔴 探索 |
|
||||
| LIF 脉冲扩散 | - | 🔴 探索 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 本阶段产出要求
|
||||
|
||||
- [x] 团队对 Jarvis 当前 RAG 问题和目标方向达成一致
|
||||
- [x] VCPToolBox 借鉴点已映射到具体 Phase
|
||||
- [x] 后续 phase 文档能够在这个认知基础上展开
|
||||
188
development-doc/plan/rag-update/phase-r-1-token-chunking.md
Normal file
188
development-doc/plan/rag-update/phase-r-1-token-chunking.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Phase R.1:Token 感知分块优化
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已规划
|
||||
依赖:R.0(现状与目标)
|
||||
工作量:3 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
解决跨块边界信息丢失问题,实现精确的 token 计数和重叠分块。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心任务
|
||||
|
||||
### Task R.1.1:集成 tiktoken
|
||||
|
||||
**目标:** 使用 tiktoken 精确计算 token 数,85% 安全边界
|
||||
|
||||
**新增文件:** `backend/app/services/chunker.py`
|
||||
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
class TokenAwareChunker:
|
||||
"""Token 感知分块器,85% 安全边界 + 10% 重叠"""
|
||||
|
||||
def __init__(self, max_tokens: int = 8000, overlap_ratio: float = 0.1):
|
||||
self.encoding = tiktoken.get_encoding("cl100k_base")
|
||||
self.safe_max = int(max_tokens * 0.85)
|
||||
self.overlap_tokens = int(self.safe_max * overlap_ratio)
|
||||
|
||||
def count_tokens(self, text: str) -> int:
|
||||
return len(self.encoding.encode(text))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.1.2:实现智能断句
|
||||
|
||||
**目标:** 在断点处(标点/空白)智能断开,避免在词汇中间断开
|
||||
|
||||
```python
|
||||
BREAK_POINTS = ['\n', '。', '!', '?', ',', ';', ':', ' ', '\t']
|
||||
|
||||
def find_best_breakpoint(text: str, max_pos: int) -> int:
|
||||
"""在 max_pos 附近找到最佳断点(标点/空白处)"""
|
||||
for i in range(max_pos - 1, max(0, max_pos - 200), -1):
|
||||
if text[i] in BREAK_POINTS:
|
||||
return i + 1
|
||||
return max_pos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.1.3:实现重叠分块
|
||||
|
||||
**目标:** 10% token 重叠,保证上下文连续性
|
||||
|
||||
```python
|
||||
def chunk_with_overlap(self, text: str) -> list[dict]:
|
||||
"""带重叠的分块器,上一块末尾作为下一块开头"""
|
||||
sentences = self._split_sentences(text)
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
current_tokens = 0
|
||||
|
||||
for sentence in sentences:
|
||||
sentence_tokens = self.count_tokens(sentence)
|
||||
|
||||
if sentence_tokens > self.safe_max:
|
||||
# 超长句子强制分割
|
||||
forced = self._force_split_long_text(sentence)
|
||||
chunks.extend(forced)
|
||||
continue
|
||||
|
||||
if current_tokens + sentence_tokens > self.safe_max:
|
||||
chunks.append({"content": current_chunk.strip()})
|
||||
# 创建重叠部分
|
||||
current_chunk = self._create_overlap(sentences, current_tokens)
|
||||
current_tokens = self.count_tokens(current_chunk)
|
||||
|
||||
current_chunk += sentence
|
||||
current_tokens += sentence_tokens
|
||||
|
||||
if current_chunk.strip():
|
||||
chunks.append({"content": current_chunk.strip()})
|
||||
|
||||
return chunks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 修改现有文件
|
||||
|
||||
### `backend/app/services/document_service.py`
|
||||
|
||||
集成新的 TokenAwareChunker:
|
||||
|
||||
```python
|
||||
from app.services.chunker import TokenAwareChunker
|
||||
|
||||
class DocumentService:
|
||||
def __init__(self, ...):
|
||||
# ... existing init
|
||||
self.chunker = TokenAwareChunker()
|
||||
|
||||
def _build_chunks(self, parsed: ParsedDocument) -> list[dict]:
|
||||
# 原有逻辑替换为重叠分块
|
||||
chunks = self.chunker.chunk_with_overlap(parsed.summary)
|
||||
for node in parsed.nodes:
|
||||
node_chunks = self.chunker.chunk_with_overlap(node.text)
|
||||
for chunk in node_chunks:
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 新增测试
|
||||
|
||||
**新增文件:** `backend/tests/services/test_chunker.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.services.chunker import TokenAwareChunker, find_best_breakpoint
|
||||
|
||||
class TestTokenAwareChunker:
|
||||
def test_token_counting(self):
|
||||
chunker = TokenAwareChunker(max_tokens=100)
|
||||
text = "Hello, world!"
|
||||
assert chunker.count_tokens(text) > 0
|
||||
|
||||
def test_overlap_ratio(self):
|
||||
chunker = TokenAwareChunker(max_tokens=100, overlap_ratio=0.1)
|
||||
assert chunker.overlap_tokens == 10
|
||||
|
||||
def test_safe_max(self):
|
||||
chunker = TokenAwareChunker(max_tokens=100)
|
||||
assert chunker.safe_max == 85
|
||||
|
||||
class TestSmartBreakpoint:
|
||||
def test_find_breakpoint_at_punctuation(self):
|
||||
text = "Hello, world! How are you?"
|
||||
pos = find_best_breakpoint(text, 15)
|
||||
assert text[pos-1] in [',', '!', '?', '。', '!', '?']
|
||||
|
||||
class TestOverlappingChunker:
|
||||
def test_chunks_have_overlap(self):
|
||||
chunker = TokenAwareChunker(max_tokens=50, overlap_ratio=0.2)
|
||||
long_text = "A" * 200 + "." + "B" * 200
|
||||
chunks = chunker.chunk_with_overlap(long_text)
|
||||
assert len(chunks) >= 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] tiktoken 正确集成,token 计数误差 < 1%
|
||||
- [ ] 超长句子不在词汇中间断开
|
||||
- [ ] 重叠分块保证上下文连续性
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
- [ ] 集成测试通过(文档上传→分块→检索)
|
||||
|
||||
---
|
||||
|
||||
## 6. 变更文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/chunker.py` | 新增 | Token 感知分块器 |
|
||||
| `backend/app/services/document_service.py` | 修改 | 集成新的分块器 |
|
||||
| `backend/tests/services/test_chunker.py` | 新增 | 分块器单元测试 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.1.1 tiktoken 集成 | 0.5 天 |
|
||||
| R.1.2 智能断句 | 0.5 天 |
|
||||
| R.1.3 重叠分块 | 1 天 |
|
||||
| 测试 + 调试 | 1 天 |
|
||||
| **R.1 总计** | **3 天** |
|
||||
244
development-doc/plan/rag-update/phase-r-2-multi-index.md
Normal file
244
development-doc/plan/rag-update/phase-r-2-multi-index.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Phase R.2:多索引架构
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已规划
|
||||
依赖:R.1(Token 感知分块)
|
||||
工作量:4 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
按知识类型/重要性分层,支持懒加载和 LRU 淘汰。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心任务
|
||||
|
||||
### Task R.2.1:设计 Collection 分离策略
|
||||
|
||||
**目标:** 按知识类型分离 ChromaDB Collection
|
||||
|
||||
**新增文件:** `backend/app/services/multi_index.py`
|
||||
|
||||
```python
|
||||
class MultiIndexManager:
|
||||
"""多索引管理器,按知识类型分离"""
|
||||
|
||||
INDEX_STRATEGIES = {
|
||||
"default": {
|
||||
"name": "user_{user_id}_default",
|
||||
"description": "通用文档"
|
||||
},
|
||||
"important": {
|
||||
"name": "user_{user_id}_important",
|
||||
"description": "重要文档(1.2x加权)"
|
||||
},
|
||||
"code": {
|
||||
"name": "user_{user_id}_code",
|
||||
"description": "代码片段"
|
||||
},
|
||||
"meeting": {
|
||||
"name": "user_{user_id}_meeting",
|
||||
"description": "会议记录"
|
||||
},
|
||||
}
|
||||
|
||||
def get_collection(self, user_id: str, index_type: str = "default"):
|
||||
name = self.INDEX_STRATEGIES[index_type]["name"].format(user_id=user_id)
|
||||
return self.chroma_client.get_or_create_collection(name=name)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.2.2:实现懒加载 + LRU TTL
|
||||
|
||||
**目标:** 2小时 TTL,访问时加载,不访问不加载
|
||||
|
||||
```python
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
class LazyIndexLoader:
|
||||
"""懒加载索引,支持 TTL 淘汰"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 7200):
|
||||
self._cache = {}
|
||||
self._last_used = {}
|
||||
self._lock = Lock()
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
def get_or_load(self, key: str, loader_fn) -> Any:
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._last_used[key] = time.time()
|
||||
return self._cache[key]
|
||||
|
||||
value = loader_fn()
|
||||
self._cache[key] = value
|
||||
self._last_used[key] = time.time()
|
||||
return value
|
||||
|
||||
def sweep(self):
|
||||
"""清理过期索引"""
|
||||
now = time.time()
|
||||
expired = [
|
||||
k for k, t in self._last_used.items()
|
||||
if now - t > self._ttl
|
||||
]
|
||||
for k in expired:
|
||||
del self._cache[k]
|
||||
del self._last_used[k]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.2.3:实现重要性感知检索
|
||||
|
||||
**目标:** important 索引加权 1.2x
|
||||
|
||||
```python
|
||||
async def retrieve_with_importance(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
top_k: int = 5,
|
||||
) -> list[SearchResult]:
|
||||
"""重要性感知检索,优先返回高重要性文档"""
|
||||
|
||||
# 1. 从 default 索引检索
|
||||
default_results = await self.retrieve(query, user_id, top_k=top_k * 2)
|
||||
|
||||
# 2. 从 important 索引检索
|
||||
important_results = await self.retrieve(
|
||||
query, user_id,
|
||||
collection_name=f"user_{user_id}_important",
|
||||
top_k=top_k
|
||||
)
|
||||
|
||||
# 3. 合并,重要文档加权
|
||||
scored = []
|
||||
for r in default_results:
|
||||
scored.append((r.score * 0.8, r))
|
||||
for r in important_results:
|
||||
scored.append((r.score * 1.2, r)) # 重要文档 1.2x
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [r for _, r in scored[:top_k]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 修改现有文件
|
||||
|
||||
### `backend/app/models/document.py`
|
||||
|
||||
增加 `importance` 字段:
|
||||
|
||||
```python
|
||||
class Document(Base):
|
||||
# ... existing fields ...
|
||||
|
||||
importance = Column(Float, default=0.5) # 0.0 ~ 1.0, >0.8 进入 important 索引
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `backend/app/services/knowledge_service.py`
|
||||
|
||||
集成多索引支持:
|
||||
|
||||
```python
|
||||
from app.services.multi_index import MultiIndexManager, LazyIndexLoader
|
||||
|
||||
class KnowledgeService:
|
||||
def __init__(self, ...):
|
||||
# ... existing init
|
||||
self.multi_index = MultiIndexManager(self.chroma_client)
|
||||
self.lazy_loader = LazyIndexLoader(ttl_seconds=7200)
|
||||
|
||||
async def index_document(self, document_id: str, user_id: str, ...):
|
||||
# 根据 importance 选择索引
|
||||
doc = await self._get_document(document_id)
|
||||
if doc.importance >= 0.8:
|
||||
collection = self.multi_index.get_collection(user_id, "important")
|
||||
else:
|
||||
collection = self.multi_index.get_collection(user_id, "default")
|
||||
# ... rest of indexing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 新增测试
|
||||
|
||||
**新增文件:** `backend/tests/services/test_multi_index.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.services.multi_index import MultiIndexManager, LazyIndexLoader
|
||||
|
||||
class TestMultiIndexManager:
|
||||
def test_get_collection_creates_if_not_exists(self):
|
||||
manager = MultiIndexManager(mock_chroma_client)
|
||||
col = manager.get_collection("user123", "default")
|
||||
assert col is not None
|
||||
|
||||
def test_collection_name_format(self):
|
||||
manager = MultiIndexManager(mock_chroma_client)
|
||||
name = manager.INDEX_STRATEGIES["important"]["name"].format(user_id="user123")
|
||||
assert name == "user_user123_important"
|
||||
|
||||
class TestLazyIndexLoader:
|
||||
def test_get_or_load_caches(self):
|
||||
loader = LazyIndexLoader()
|
||||
load_fn = lambda: {"data": "test"}
|
||||
|
||||
result1 = loader.get_or_load("key1", load_fn)
|
||||
result2 = loader.get_or_load("key1", load_fn)
|
||||
|
||||
# 第二次调用应该返回缓存的结果,而不是重新加载
|
||||
assert result1 is result2
|
||||
|
||||
def test_sweep_removes_expired(self):
|
||||
loader = LazyIndexLoader(ttl_seconds=1)
|
||||
loader.get_or_load("key1", lambda: "value1")
|
||||
|
||||
import time
|
||||
time.sleep(1.1) # 等待过期
|
||||
|
||||
loader.sweep()
|
||||
assert "key1" not in loader._cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] 多 Collection 创建成功
|
||||
- [ ] 懒加载索引生效(访问时加载,不访问不加载)
|
||||
- [ ] TTL 淘汰机制工作(2小时无访问自动卸载)
|
||||
- [ ] 重要性感知检索加权生效
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
---
|
||||
|
||||
## 6. 变更文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/multi_index.py` | 新增 | 多索引管理器 |
|
||||
| `backend/app/services/knowledge_service.py` | 修改 | 集成多索引支持 |
|
||||
| `backend/app/models/document.py` | 修改 | 增加 importance 字段 |
|
||||
| `backend/tests/services/test_multi_index.py` | 新增 | 多索引单元测试 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.2.1 Collection 分离策略 | 1 天 |
|
||||
| R.2.2 懒加载 + LRU | 1 天 |
|
||||
| R.2.3 重要性感知检索 | 0.5 天 |
|
||||
| 测试 + 调试 | 1.5 天 |
|
||||
| **R.2 总计** | **4 天** |
|
||||
290
development-doc/plan/rag-update/phase-r-3-dynamic-weight.md
Normal file
290
development-doc/plan/rag-update/phase-r-3-dynamic-weight.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Phase R.3:动态权重增强
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已规划
|
||||
依赖:R.1(Token 感知分块)
|
||||
工作量:4.5 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
根据查询特性动态调整检索策略,支持核心标签加权。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心任务
|
||||
|
||||
### Task R.3.1:实现查询特性分析
|
||||
|
||||
**目标:** 分析查询类型(代码/表格/对话式)
|
||||
|
||||
**新增文件:** `backend/app/services/query_analyzer.py`
|
||||
|
||||
```python
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class QueryProfile:
|
||||
logic_depth: float # 逻辑深度 (0-1): 意图明确程度
|
||||
is_code_related: bool # 是否代码相关
|
||||
is_table_related: bool # 是否表格相关
|
||||
keyword_density: float # 关键词密度
|
||||
is_conversational: bool # 是否对话式查询
|
||||
|
||||
class QueryAnalyzer:
|
||||
CODE_KEYWORDS = {'code', 'function', 'class', 'api', 'python', 'js', 'bug', '函数', '代码'}
|
||||
TABLE_KEYWORDS = {'table', 'sheet', 'excel', 'csv', 'column', 'row', '数据', '统计', '表格', '列', '行'}
|
||||
|
||||
def analyze(self, query: str) -> QueryProfile:
|
||||
words = set(re.findall(r'\w+', query.lower()))
|
||||
|
||||
return QueryProfile(
|
||||
logic_depth=self._calc_logic_depth(query),
|
||||
is_code_related=bool(words & self.CODE_KEYWORDS),
|
||||
is_table_related=bool(words & self.TABLE_KEYWORDS),
|
||||
keyword_density=len(words) / max(len(query), 1),
|
||||
is_conversational=self._is_conversational(query),
|
||||
)
|
||||
|
||||
def _calc_logic_depth(self, query: str) -> float:
|
||||
"""计算逻辑深度:问句、具体名词越多越聚焦"""
|
||||
question_markers = ['how', 'why', 'what', 'which', '哪个', '如何', '为什么', '怎么']
|
||||
has_question = any(q in query.lower() for q in question_markers)
|
||||
has_specific_terms = len(re.findall(r'\w{5,}', query)) > 3
|
||||
return 0.8 if (has_question and has_specific_terms) else 0.5
|
||||
|
||||
def _is_conversational(self, query: str) -> bool:
|
||||
"""判断是否为对话式查询"""
|
||||
conversational_patterns = ['你', '我想', '能不能', '可以帮我', 'what do you think']
|
||||
return any(p in query for p in conversational_patterns)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.3.2:实现动态 Reranker
|
||||
|
||||
**目标:** 根据查询类型动态调整语义/关键词/标题权重
|
||||
|
||||
**新增文件:** `backend/app/services/dynamic_reranker.py`
|
||||
|
||||
```python
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
class DynamicReranker:
|
||||
"""动态 Reranker,根据查询特性调整权重"""
|
||||
|
||||
def rerank(
|
||||
self,
|
||||
query: str,
|
||||
results: list[SearchResult],
|
||||
analyzer: QueryAnalyzer
|
||||
) -> list[SearchResult]:
|
||||
profile = analyzer.analyze(query)
|
||||
weights = self._get_weights(profile)
|
||||
beta = self._calc_beta(profile)
|
||||
|
||||
scored = []
|
||||
for r in results:
|
||||
score = r.score * weights["semantic"]
|
||||
score += self._keyword_score(query, r.content) * weights["keyword"]
|
||||
score += self._title_score(query, r.document_title) * weights["title"]
|
||||
|
||||
# 表格内容加分
|
||||
if profile.is_table_related:
|
||||
meta = json.loads(r.metadata_ or "{}")
|
||||
if meta.get("content_type") == "table_schema":
|
||||
score += 0.25
|
||||
elif meta.get("content_type") == "table_rows":
|
||||
score += 0.15
|
||||
|
||||
score *= beta
|
||||
scored.append((score, r))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [r for _, r in scored]
|
||||
|
||||
def _get_weights(self, profile: QueryProfile) -> dict:
|
||||
if profile.is_code_related:
|
||||
return {"semantic": 0.55, "keyword": 0.35, "title": 0.10}
|
||||
elif profile.is_table_related:
|
||||
return {"semantic": 0.50, "keyword": 0.30, "title": 0.20}
|
||||
elif profile.is_conversational:
|
||||
return {"semantic": 0.85, "keyword": 0.10, "title": 0.05}
|
||||
else:
|
||||
return {"semantic": 0.70, "keyword": 0.20, "title": 0.10}
|
||||
|
||||
def _calc_beta(self, profile: QueryProfile) -> float:
|
||||
"""计算动态 Beta:逻辑深度高时加大语义权重"""
|
||||
if profile.logic_depth > 0.7:
|
||||
return 1.2 # 意图明确,加大权重
|
||||
elif profile.logic_depth < 0.4:
|
||||
return 0.8 # 意图模糊,降低权重
|
||||
return 1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.3.3:实现核心标签系统
|
||||
|
||||
**目标:** 核心标签 1.33x 加权
|
||||
|
||||
**新增文件:** `backend/app/services/core_tag_search.py`
|
||||
|
||||
```python
|
||||
class CoreTagAwareSearch:
|
||||
"""核心标签感知检索"""
|
||||
|
||||
CORE_BOOST_FACTOR = 1.33 # 33% 加权
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
core_tags: list[str] = None,
|
||||
base_search_fn: callable
|
||||
) -> list[SearchResult]:
|
||||
results = await base_search_fn(query, user_id)
|
||||
|
||||
if core_tags:
|
||||
for r in results:
|
||||
meta = json.loads(r.metadata_ or "{}")
|
||||
chunk_tags = meta.get("tags", [])
|
||||
|
||||
if any(tag in chunk_tags for tag in core_tags):
|
||||
r.score *= self.CORE_BOOST_FACTOR
|
||||
|
||||
return sorted(results, key=lambda x: x.score, reverse=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 修改现有文件
|
||||
|
||||
### `backend/app/models/document.py`
|
||||
|
||||
增加 `tags` 和 `is_core` 字段:
|
||||
|
||||
```python
|
||||
class DocumentChunk(Base):
|
||||
# ... existing fields ...
|
||||
|
||||
tags = Column(JSON, default=list) # ["重要", "代码", "架构"]
|
||||
is_core = Column(Boolean, default=False) # 是否核心切片
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `backend/app/services/knowledge_service.py`
|
||||
|
||||
集成动态权重:
|
||||
|
||||
```python
|
||||
from app.services.query_analyzer import QueryAnalyzer
|
||||
from app.services.dynamic_reranker import DynamicReranker
|
||||
from app.services.core_tag_search import CoreTagAwareSearch
|
||||
|
||||
class KnowledgeService:
|
||||
def __init__(self, ...):
|
||||
# ... existing init
|
||||
self.query_analyzer = QueryAnalyzer()
|
||||
self.dynamic_reranker = DynamicReranker()
|
||||
self.core_tag_search = CoreTagAwareSearch()
|
||||
|
||||
async def retrieve(self, query: str, user_id: str, ..., core_tags: list[str] = None) -> list[SearchResult]:
|
||||
# ... existing retrieval logic ...
|
||||
|
||||
# 动态 Rerank
|
||||
results = self.dynamic_reranker.rerank(
|
||||
query, results, self.query_analyzer
|
||||
)
|
||||
|
||||
# 核心标签加权
|
||||
if core_tags:
|
||||
results = await self.core_tag_search.search(
|
||||
query, user_id, core_tags,
|
||||
lambda q, u: results # 使用已检索的结果
|
||||
)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 新增测试
|
||||
|
||||
**新增文件:** `backend/tests/services/test_dynamic_reranker.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.services.query_analyzer import QueryAnalyzer, QueryProfile
|
||||
from app.services.dynamic_reranker import DynamicReranker
|
||||
|
||||
class TestQueryAnalyzer:
|
||||
def test_code_query_detection(self):
|
||||
analyzer = QueryAnalyzer()
|
||||
profile = analyzer.analyze("请解释这段 Python 代码")
|
||||
assert profile.is_code_related is True
|
||||
|
||||
def test_table_query_detection(self):
|
||||
analyzer = QueryAnalyzer()
|
||||
profile = analyzer.analyze("统计这个 Excel 表格的总和")
|
||||
assert profile.is_table_related is True
|
||||
|
||||
def test_conversational_detection(self):
|
||||
analyzer = QueryAnalyzer()
|
||||
profile = analyzer.analyze("我想了解一下")
|
||||
assert profile.is_conversational is True
|
||||
|
||||
class TestDynamicReranker:
|
||||
def test_code_query_weights(self):
|
||||
reranker = DynamicReranker()
|
||||
analyzer = QueryAnalyzer()
|
||||
|
||||
profile = QueryProfile(
|
||||
logic_depth=0.5,
|
||||
is_code_related=True,
|
||||
is_table_related=False,
|
||||
keyword_density=0.3,
|
||||
is_conversational=False
|
||||
)
|
||||
|
||||
weights = reranker._get_weights(profile)
|
||||
assert weights["keyword"] > weights["semantic"] * 0.5 # 代码查询关键词权重较高
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] 查询特性分析准确(代码/表格/对话式识别)
|
||||
- [ ] 动态权重根据查询类型调整
|
||||
- [ ] 核心标签检索加权 1.33x
|
||||
- [ ] Rerank 集成测试通过
|
||||
|
||||
---
|
||||
|
||||
## 6. 变更文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/query_analyzer.py` | 新增 | 查询特性分析 |
|
||||
| `backend/app/services/dynamic_reranker.py` | 新增 | 动态 Reranker |
|
||||
| `backend/app/services/core_tag_search.py` | 新增 | 核心标签检索 |
|
||||
| `backend/app/services/knowledge_service.py` | 修改 | 集成动态权重 |
|
||||
| `backend/app/models/document.py` | 修改 | 增加 tags/is_core 字段 |
|
||||
| `backend/tests/services/test_dynamic_reranker.py` | 新增 | 动态 Reranker 测试 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.3.1 查询特性分析 | 1 天 |
|
||||
| R.3.2 动态 Reranker | 1 天 |
|
||||
| R.3.3 核心标签系统 | 1 天 |
|
||||
| 测试 + 调试 | 1.5 天 |
|
||||
| **R.3 总计** | **4.5 天** |
|
||||
255
development-doc/plan/rag-update/phase-r-4-advanced.md
Normal file
255
development-doc/plan/rag-update/phase-r-4-advanced.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Phase R.4:高级特性(可选)
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已规划(可选)
|
||||
工作量:4.5 天
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
探索更高级的 RAG 增强技术。
|
||||
|
||||
> **注意:** 本阶段为可选特性,不影响核心功能。根据实际需求决定是否实施。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心任务
|
||||
|
||||
### Task R.4.1:语义去重
|
||||
|
||||
**目标:** 消除冗余检索结果
|
||||
|
||||
**新增文件:** `backend/app/services/deduplicator.py`
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
class SemanticDeduplicator:
|
||||
"""语义去重,消除冗余检索结果"""
|
||||
|
||||
DEDUP_THRESHOLD = 0.88 # 余弦相似度阈值
|
||||
|
||||
def deduplicate(
|
||||
self,
|
||||
results: list[SearchResult],
|
||||
embeddings: list[np.ndarray]
|
||||
) -> list[SearchResult]:
|
||||
if len(results) <= 1:
|
||||
return results
|
||||
|
||||
# 计算余弦相似度矩阵
|
||||
n = len(results)
|
||||
similarity_matrix = np.zeros((n, n))
|
||||
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
sim = self._cosine_similarity(embeddings[i], embeddings[j])
|
||||
similarity_matrix[i][j] = sim
|
||||
similarity_matrix[j][i] = sim
|
||||
|
||||
# 贪心去重
|
||||
keep = [True] * n
|
||||
for i in range(n):
|
||||
if not keep[i]:
|
||||
continue
|
||||
for j in range(i + 1, n):
|
||||
if keep[j] and similarity_matrix[i][j] > self.DEDUP_THRESHOLD:
|
||||
keep[j] = False
|
||||
|
||||
return [r for r, k in zip(results, keep) if k]
|
||||
|
||||
def _cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
|
||||
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.4.2:语义分桶(可选)
|
||||
|
||||
**目标:** 按主题自动组织检索结果
|
||||
|
||||
**新增文件:** `backend/app/services/semantic_bucket.py`
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
|
||||
class SemanticBucketing:
|
||||
"""语义分桶,按主题自动组织检索结果"""
|
||||
|
||||
async def bucket_by_topic(
|
||||
self,
|
||||
results: list[SearchResult],
|
||||
embeddings: list[np.ndarray]
|
||||
) -> dict[str, list[SearchResult]]:
|
||||
# 使用层次聚类
|
||||
from sklearn.cluster import AgglomerativeClustering
|
||||
|
||||
n_clusters = min(5, len(results))
|
||||
if n_clusters < 2:
|
||||
return {"default": results}
|
||||
|
||||
clusterer = AgglomerativeClustering(n_clusters=n_clusters)
|
||||
labels = clusterer.fit_predict(np.array(embeddings))
|
||||
|
||||
buckets = defaultdict(list)
|
||||
for r, label in zip(results, labels):
|
||||
buckets[f"topic_{label}"].append(r)
|
||||
|
||||
# 按每个桶内最高分排序
|
||||
sorted_buckets = {}
|
||||
for name, items in buckets.items():
|
||||
sorted_items = sorted(items, key=lambda x: x.score, reverse=True)
|
||||
sorted_buckets[name] = sorted_items
|
||||
|
||||
return sorted_buckets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task R.4.3:EPA 分析设计(探索)
|
||||
|
||||
**目标:** 语义空间投影分析方案设计
|
||||
|
||||
```python
|
||||
class EPAModule:
|
||||
"""
|
||||
EPA: Embedding Projection Analysis
|
||||
|
||||
分析向量在语义空间中的投影,识别:
|
||||
- 逻辑深度 (Logic Depth): 意图聚焦程度
|
||||
- 熵 (Entropy): 信息散乱程度
|
||||
- 共振 (Resonance): 跨域关联程度
|
||||
|
||||
注意:此模块为高级特性,复杂度高,建议后续探索。
|
||||
"""
|
||||
|
||||
def project(self, vector: np.ndarray) -> dict:
|
||||
"""
|
||||
返回语义投影结果:
|
||||
- logic_depth: 0~1, 高=意图聚焦
|
||||
- entropy: 0~1, 高=信息散乱
|
||||
- resonance: 跨域共振程度
|
||||
- dominant_axes: 主要语义轴
|
||||
"""
|
||||
raise NotImplementedError("EPA 模块探索中")
|
||||
|
||||
def detect_cross_domain_resonance(self, vector: np.ndarray) -> dict:
|
||||
"""
|
||||
检测跨域共振:
|
||||
- 当查询同时触及多个正交语义轴时触发
|
||||
- 返回共振强度和涉及的主要领域
|
||||
"""
|
||||
raise NotImplementedError("EPA 模块探索中")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 新增测试
|
||||
|
||||
```python
|
||||
# backend/tests/services/test_deduplicator.py
|
||||
|
||||
class TestSemanticDeduplicator:
|
||||
def test_deduplicate_similar_results(self):
|
||||
dedup = SemanticDeduplicator()
|
||||
|
||||
results = [
|
||||
SearchResult(chunk_id="1", score=0.9, ...),
|
||||
SearchResult(chunk_id="2", score=0.85, ...),
|
||||
SearchResult(chunk_id="3", score=0.8, ...),
|
||||
]
|
||||
embeddings = [
|
||||
np.array([0.1, 0.2, 0.3]),
|
||||
np.array([0.11, 0.21, 0.31]), # 与第一个高度相似
|
||||
np.array([0.9, 0.8, 0.7]), # 与第一个不相似
|
||||
]
|
||||
|
||||
deduped = dedup.deduplicate(results, embeddings)
|
||||
assert len(deduped) < len(results) # 应该去掉一些重复结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [ ] 语义去重测试通过
|
||||
- [ ] 语义分桶原型完成(可选)
|
||||
- [ ] EPA 分析方案设计完成(可选实现)
|
||||
|
||||
---
|
||||
|
||||
## 5. 变更文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/deduplicator.py` | 新增 | 语义去重 |
|
||||
| `backend/app/services/semantic_bucket.py` | 新增(可选) | 语义分桶 |
|
||||
| `backend/tests/services/test_deduplicator.py` | 新增 | 去重测试 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 工作量估算
|
||||
|
||||
| 任务 | 估算 | 状态 |
|
||||
|------|------|------|
|
||||
| R.4.1 语义去重 | 1.5 天 | 必须 |
|
||||
| R.4.2 语义分桶 | 2 天 | 可选 |
|
||||
| R.4.3 EPA 设计 | 1 天 | 可选 |
|
||||
| **R.4 总计(必须)** | **1.5 天** | |
|
||||
| **R.4 总计(含可选)** | **4.5 天** | |
|
||||
|
||||
---
|
||||
|
||||
## 7. EPA 分析详细设计(供后续参考)
|
||||
|
||||
### 7.1 核心概念
|
||||
|
||||
EPA (Embedding Projection Analysis) 受 VCPToolBox TagMemo V6 启发,用于分析查询向量在语义空间中的投影特征。
|
||||
|
||||
### 7.2 关键指标
|
||||
|
||||
| 指标 | 定义 | 计算方式 |
|
||||
|------|------|----------|
|
||||
| Logic Depth | 意图聚焦程度 | 通过计算投影熵值判断 |
|
||||
| Entropy | 信息散乱程度 | 向量分布的熵 |
|
||||
| Resonance | 跨域共振 | 查询跨越多个语义轴的程度 |
|
||||
|
||||
### 7.3 动态 Beta 公式
|
||||
|
||||
```
|
||||
β = σ(L · log(1 + R) - S · noise_penalty)
|
||||
```
|
||||
|
||||
- L: Logic Depth
|
||||
- R: Resonance
|
||||
- S: 噪音程度
|
||||
- σ: 归一化函数
|
||||
|
||||
### 7.4 残差金字塔
|
||||
|
||||
对查询向量进行多级剥离:
|
||||
|
||||
1. 首轮匹配 → 获取主要语义
|
||||
2. 计算残差 → 提取被掩盖的微弱信号
|
||||
3. 递归搜索 → 直到 90% 能量被解释
|
||||
|
||||
### 7.5 LIF 脉冲扩散
|
||||
|
||||
模拟神经元的脉冲传导:
|
||||
|
||||
1. 种子节点激活
|
||||
2. 沿共现矩阵向外扩散(2跳限制)
|
||||
3. 阈值过滤噪音
|
||||
4. 涌现拓扑关联
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与注意事项
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| EPA 实现复杂度高 | 高 | Phase R.4 可选,暂不实现 |
|
||||
| 聚类计算开销 | 中 | 限制聚类数量,使用高效算法 |
|
||||
| 去重阈值调参 | 中 | 提供配置项,允许用户调整 |
|
||||
601
development-doc/plan/rag-update/phase-r-rag-upgrade.md
Normal file
601
development-doc/plan/rag-update/phase-r-rag-upgrade.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# Phase R:RAG 系统升级专项
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已规划
|
||||
借鉴来源:VCPToolBox TagMemo V6 架构
|
||||
|
||||
---
|
||||
|
||||
## R.0 当前现状与目标
|
||||
|
||||
### R.0.1 当前 Jarvis RAG 架构
|
||||
|
||||
```
|
||||
用户上传文档 → DocumentService (解析/分块) → ChromaDB (向量存储) → KnowledgeService (检索)
|
||||
```
|
||||
|
||||
**核心文件:**
|
||||
- `backend/app/services/document_service.py` - 文档上传/解析/分块
|
||||
- `backend/app/services/knowledge_service.py` - ChromaDB 向量检索/混合检索
|
||||
- `backend/app/models/document.py` - Document/DocumentChunk 数据模型
|
||||
|
||||
### R.0.2 当前能力矩阵
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 多格式文档解析 | ✅ | PDF/MD/TXT/DOCX/CSV/XLSX |
|
||||
| 结构化分块 | ✅ | 基于标题层级、表格、段落 |
|
||||
| 向量检索 | ✅ | ChromaDB 语义相似度 |
|
||||
| 关键词检索 | ✅ | SQL LIKE |
|
||||
| 混合检索 | ✅ | 向量 + 关键词加权 |
|
||||
| Rerank | ✅ | 语义分*0.7 + 关键词*0.2 + 标题*0.1 |
|
||||
| 上下文丰富 | ✅ | 自动获取前/后 chunk |
|
||||
|
||||
### R.0.3 当前短板
|
||||
|
||||
| 短板 | 严重程度 | 影响 |
|
||||
|------|----------|------|
|
||||
| 无重叠分块 | 🟡 中 | 跨块边界信息丢失 |
|
||||
| 单索引架构 | 🟡 中 | 无法按知识类型/重要性分层 |
|
||||
| 无动态权重 | 🟡 中 | 检索策略静态,不适配查询类型 |
|
||||
| 无 Tag/标签系统 | 🟡 中 | 无法利用语义标签增强检索 |
|
||||
| 无懒加载机制 | 🟢 低 | 大量文档时内存占用高 |
|
||||
| 无遗忘机制 | 🟢 低 | 存储无限增长 |
|
||||
|
||||
### R.0.4 VCPToolBox TagMemo 核心借鉴
|
||||
|
||||
```
|
||||
日记文件变化 → TextChunker(Token感知分块85%+10%重叠)
|
||||
→ EmbeddingUtils(并发批量向量化)
|
||||
→ SQLite(元数据) + VexusIndex(Rust HNSW向量索引)
|
||||
```
|
||||
|
||||
**TagMemo V6 检索流程:**
|
||||
```
|
||||
Query → EPA分析(逻辑深度L/共振R) → 残差金字塔 → TagBoost(β动态权重)
|
||||
→ LIF脉冲扩散(2跳) → 向量融合 → VexusIndex搜索
|
||||
```
|
||||
|
||||
### R.0.5 目标架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User Query │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ Query Analyzer │ ← R.3 新增
|
||||
│ (查询特性分析) │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────┐ ┌──────────────┐
|
||||
│ Default │ │ Important │ │ Code/Meeting │
|
||||
│ Collection│ │ Collection │ │ Collections │
|
||||
└────┬─────┘ └─────┬─────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└──────────────────┼─────────────────┘
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Dynamic Reranker │ ← R.3 新增
|
||||
│ (Core Tag Boost + 动态权重)│
|
||||
└───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Search Result │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## R.1 Token 感知分块优化
|
||||
|
||||
**目标:** 解决跨块边界信息丢失问题,实现精确的 token 计数和重叠分块
|
||||
|
||||
### R.1.1 核心任务
|
||||
|
||||
#### Task R.1.1.1:集成 tiktoken
|
||||
|
||||
```python
|
||||
# services/chunker.py (新增)
|
||||
import tiktoken
|
||||
|
||||
class TokenAwareChunker:
|
||||
"""Token 感知分块器,85% 安全边界 + 10% 重叠"""
|
||||
|
||||
def __init__(self, max_tokens: int = 8000, overlap_ratio: float = 0.1):
|
||||
self.encoding = tiktoken.get_encoding("cl100k_base")
|
||||
self.safe_max = int(max_tokens * 0.85)
|
||||
self.overlap_tokens = int(self.safe_max * overlap_ratio)
|
||||
|
||||
def count_tokens(self, text: str) -> int:
|
||||
return len(self.encoding.encode(text))
|
||||
```
|
||||
|
||||
#### Task R.1.1.2:实现智能断句
|
||||
|
||||
```python
|
||||
BREAK_POINTS = ['\n', '。', '!', '?', ',', ';', ':', ' ', '\t']
|
||||
|
||||
def find_best_breakpoint(text: str, max_pos: int) -> int:
|
||||
"""在 max_pos 附近找到最佳断点(标点/空白处)"""
|
||||
for i in range(max_pos - 1, max(0, max_pos - 200), -1):
|
||||
if text[i] in BREAK_POINTS:
|
||||
return i + 1
|
||||
return max_pos
|
||||
```
|
||||
|
||||
#### Task R.1.1.3:实现重叠分块
|
||||
|
||||
```python
|
||||
def chunk_with_overlap(self, text: str) -> list[dict]:
|
||||
"""带重叠的分块器,上一块末尾作为下一块开头"""
|
||||
sentences = self._split_sentences(text)
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
current_tokens = 0
|
||||
|
||||
for sentence in sentences:
|
||||
sentence_tokens = self.count_tokens(sentence)
|
||||
|
||||
if sentence_tokens > self.safe_max:
|
||||
# 超长句子强制分割
|
||||
forced = self._force_split_long_text(sentence)
|
||||
chunks.extend(forced)
|
||||
continue
|
||||
|
||||
if current_tokens + sentence_tokens > self.safe_max:
|
||||
chunks.append({"content": current_chunk.strip()})
|
||||
# 创建重叠部分
|
||||
current_chunk = self._create_overlap(sentences, current_tokens)
|
||||
current_tokens = self.count_tokens(current_chunk)
|
||||
|
||||
current_chunk += sentence
|
||||
current_tokens += sentence_tokens
|
||||
|
||||
if current_chunk.strip():
|
||||
chunks.append({"content": current_chunk.strip()})
|
||||
|
||||
return chunks
|
||||
```
|
||||
|
||||
### R.1.2 验收标准
|
||||
|
||||
- [ ] tiktoken 正确集成,token 计数误差 < 1%
|
||||
- [ ] 超长句子不在词汇中间断开
|
||||
- [ ] 重叠分块保证上下文连续性
|
||||
- [ ] 单元测试覆盖率 > 80%
|
||||
|
||||
### R.1.3 变更文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `services/chunker.py` | 新增 | Token 感知分块器 |
|
||||
| `services/document_service.py` | 修改 | 集成新的分块器 |
|
||||
| `tests/test_chunker.py` | 新增 | 分块器单元测试 |
|
||||
|
||||
### R.1.4 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.1.1.1 tiktoken 集成 | 0.5 天 |
|
||||
| R.1.1.2 智能断句 | 0.5 天 |
|
||||
| R.1.1.3 重叠分块 | 1 天 |
|
||||
| 测试 + 调试 | 1 天 |
|
||||
| **R.1 总计** | **3 天** |
|
||||
|
||||
---
|
||||
|
||||
## R.2 多索引架构
|
||||
|
||||
**目标:** 按知识类型/重要性分层,支持懒加载和 LRU 淘汰
|
||||
|
||||
### R.2.1 核心任务
|
||||
|
||||
#### Task R.2.1.1:设计 Collection 分离策略
|
||||
|
||||
```python
|
||||
# services/multi_index.py (新增)
|
||||
class MultiIndexManager:
|
||||
"""多索引管理器,按知识类型分离"""
|
||||
|
||||
INDEX_STRATEGIES = {
|
||||
"default": {"name": "user_{user_id}_default", "description": "通用文档"},
|
||||
"important": {"name": "user_{user_id}_important", "description": "重要文档(1.2x加权)"},
|
||||
"code": {"name": "user_{user_id}_code", "description": "代码片段"},
|
||||
"meeting": {"name": "user_{user_id}_meeting", "description": "会议记录"},
|
||||
}
|
||||
|
||||
def get_collection(self, user_id: str, index_type: str = "default"):
|
||||
name = self.INDEX_STRATEGIES[index_type]["name"].format(user_id=user_id)
|
||||
return self.chroma_client.get_or_create_collection(name=name)
|
||||
```
|
||||
|
||||
#### Task R.2.1.2:实现懒加载 + LRU TTL
|
||||
|
||||
```python
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
class LazyIndexLoader:
|
||||
"""懒加载索引,支持 TTL 淘汰"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 7200):
|
||||
self._cache = {}
|
||||
self._last_used = {}
|
||||
self._lock = Lock()
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
def get_or_load(self, key: str, loader_fn) -> Any:
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
self._last_used[key] = time.time()
|
||||
return self._cache[key]
|
||||
|
||||
value = loader_fn()
|
||||
self._cache[key] = value
|
||||
self._last_used[key] = time.time()
|
||||
return value
|
||||
|
||||
def sweep(self):
|
||||
"""清理过期索引"""
|
||||
now = time.time()
|
||||
expired = [k for k, t in self._last_used.items() if now - t > self._ttl]
|
||||
for k in expired:
|
||||
del self._cache[k]
|
||||
del self._last_used[k]
|
||||
```
|
||||
|
||||
#### Task R.2.1.3:实现重要性感知检索
|
||||
|
||||
```python
|
||||
async def retrieve_with_importance(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
importance_threshold: float = 0.0,
|
||||
top_k: int = 5,
|
||||
) -> list[SearchResult]:
|
||||
"""重要性感知检索,优先返回高重要性文档"""
|
||||
|
||||
# 1. 从 default 索引检索
|
||||
default_results = await self.retrieve(query, user_id, top_k=top_k * 2)
|
||||
|
||||
# 2. 从 important 索引检索
|
||||
important_results = await self.retrieve(
|
||||
query, user_id,
|
||||
collection_name=f"user_{user_id}_important",
|
||||
top_k=top_k
|
||||
)
|
||||
|
||||
# 3. 合并,重要文档加权
|
||||
scored = []
|
||||
for r in default_results:
|
||||
scored.append((r.score * 0.8, r))
|
||||
for r in important_results:
|
||||
scored.append((r.score * 1.2, r)) # 重要文档 1.2x
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [r for _, r in scored[:top_k]]
|
||||
```
|
||||
|
||||
### R.2.2 验收标准
|
||||
|
||||
- [ ] 多 Collection 创建成功
|
||||
- [ ] 懒加载索引生效(访问时加载,不访问不加载)
|
||||
- [ ] TTL 淘汰机制工作(2小时无访问自动卸载)
|
||||
- [ ] 重要性感知检索加权生效
|
||||
|
||||
### R.2.3 变更文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `services/multi_index.py` | 新增 | 多索引管理器 |
|
||||
| `services/knowledge_service.py` | 修改 | 集成多索引支持 |
|
||||
| `models/document.py` | 修改 | 增加 importance 字段 |
|
||||
| `tests/test_multi_index.py` | 新增 | 多索引单元测试 |
|
||||
|
||||
### R.2.4 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.2.1.1 Collection 分离策略 | 1 天 |
|
||||
| R.2.1.2 懒加载 + LRU | 1 天 |
|
||||
| R.2.1.3 重要性感知检索 | 0.5 天 |
|
||||
| 测试 + 调试 | 1.5 天 |
|
||||
| **R.2 总计** | **4 天** |
|
||||
|
||||
---
|
||||
|
||||
## R.3 动态权重增强
|
||||
|
||||
**目标:** 根据查询特性动态调整检索策略,支持核心标签加权
|
||||
|
||||
### R.3.1 核心任务
|
||||
|
||||
#### Task R.3.1.1:实现查询特性分析
|
||||
|
||||
```python
|
||||
# services/query_analyzer.py (新增)
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class QueryProfile:
|
||||
logic_depth: float # 逻辑深度 (0-1): 意图明确程度
|
||||
is_code_related: bool # 是否代码相关
|
||||
is_table_related: bool # 是否表格相关
|
||||
keyword_density: float # 关键词密度
|
||||
is_conversational: bool # 是否对话式查询
|
||||
|
||||
class QueryAnalyzer:
|
||||
CODE_KEYWORDS = {'code', 'function', 'class', 'api', 'python', 'js', 'bug'}
|
||||
TABLE_KEYWORDS = {'table', 'sheet', 'excel', 'csv', 'column', 'row', '数据', '统计'}
|
||||
|
||||
def analyze(self, query: str) -> QueryProfile:
|
||||
words = set(re.findall(r'\w+', query.lower()))
|
||||
|
||||
return QueryProfile(
|
||||
logic_depth=self._calc_logic_depth(query),
|
||||
is_code_related=bool(words & self.CODE_KEYWORDS),
|
||||
is_table_related=bool(words & self.TABLE_KEYWORDS),
|
||||
keyword_density=len(words) / max(len(query), 1),
|
||||
is_conversational=self._is_conversational(query),
|
||||
)
|
||||
```
|
||||
|
||||
#### Task R.3.1.2:实现动态 Reranker
|
||||
|
||||
```python
|
||||
# services/dynamic_reranker.py (新增)
|
||||
class DynamicReranker:
|
||||
def rerank(self, query: str, results: list[SearchResult]) -> list[SearchResult]:
|
||||
profile = QueryAnalyzer().analyze(query)
|
||||
|
||||
# 根据查询类型调整权重
|
||||
weights = self._get_weights(profile)
|
||||
beta = self._calc_beta(profile)
|
||||
|
||||
scored = []
|
||||
for r in results:
|
||||
score = r.score * weights["semantic"]
|
||||
score += self._keyword_score(query, r.content) * weights["keyword"]
|
||||
score += self._title_score(query, r.document_title) * weights["title"]
|
||||
|
||||
# 表格内容加分
|
||||
if profile.is_table_related:
|
||||
meta = json.loads(r.metadata_ or "{}")
|
||||
if meta.get("content_type") == "table_schema":
|
||||
score += 0.25
|
||||
|
||||
score *= beta
|
||||
scored.append((score, r))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [r for _, r in scored]
|
||||
|
||||
def _get_weights(self, profile: QueryProfile) -> dict:
|
||||
if profile.is_code_related:
|
||||
return {"semantic": 0.55, "keyword": 0.35, "title": 0.10}
|
||||
elif profile.is_table_related:
|
||||
return {"semantic": 0.50, "keyword": 0.30, "title": 0.20}
|
||||
elif profile.is_conversational:
|
||||
return {"semantic": 0.85, "keyword": 0.10, "title": 0.05}
|
||||
else:
|
||||
return {"semantic": 0.70, "keyword": 0.20, "title": 0.10}
|
||||
```
|
||||
|
||||
#### Task R.3.1.3:实现核心标签系统
|
||||
|
||||
```python
|
||||
# 在 models/document.py 中增加 tags 字段
|
||||
class DocumentChunk(Base):
|
||||
tags = Column(JSON, default=list) # ["重要", "代码", "架构"]
|
||||
is_core = Column(Boolean, default=False) # 是否核心切片
|
||||
|
||||
# services/core_tag_search.py (新增)
|
||||
class CoreTagAwareSearch:
|
||||
CORE_BOOST_FACTOR = 1.33 # 33% 加权
|
||||
|
||||
async def search(self, query: str, user_id: str,
|
||||
core_tags: list[str] = None) -> list[SearchResult]:
|
||||
results = await self.base_search(query, user_id)
|
||||
|
||||
if core_tags:
|
||||
for r in results:
|
||||
meta = json.loads(r.metadata_ or "{}")
|
||||
chunk_tags = meta.get("tags", [])
|
||||
|
||||
if any(tag in chunk_tags for tag in core_tags):
|
||||
r.score *= self.CORE_BOOST_FACTOR
|
||||
|
||||
return sorted(results, key=lambda x: x.score, reverse=True)
|
||||
```
|
||||
|
||||
### R.3.2 验收标准
|
||||
|
||||
- [ ] 查询特性分析准确(代码/表格/对话式识别)
|
||||
- [ ] 动态权重根据查询类型调整
|
||||
- [ ] 核心标签检索加权 1.33x
|
||||
- [ ] Rerank 集成测试通过
|
||||
|
||||
### R.3.3 变更文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `services/query_analyzer.py` | 新增 | 查询特性分析 |
|
||||
| `services/dynamic_reranker.py` | 新增 | 动态 Reranker |
|
||||
| `services/core_tag_search.py` | 新增 | 核心标签检索 |
|
||||
| `services/knowledge_service.py` | 修改 | 集成动态权重 |
|
||||
| `models/document.py` | 修改 | 增加 tags/is_core 字段 |
|
||||
| `tests/test_dynamic_reranker.py` | 新增 | 动态 Reranker 测试 |
|
||||
|
||||
### R.3.4 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.3.1.1 查询特性分析 | 1 天 |
|
||||
| R.3.1.2 动态 Reranker | 1 天 |
|
||||
| R.3.1.3 核心标签系统 | 1 天 |
|
||||
| 测试 + 调试 | 1.5 天 |
|
||||
| **R.3 总计** | **4.5 天** |
|
||||
|
||||
---
|
||||
|
||||
## R.4 高级特性(可选)
|
||||
|
||||
**目标:** 探索更高级的 RAG 增强技术
|
||||
|
||||
### R.4.1 Task R.4.1.1:语义去重
|
||||
|
||||
```python
|
||||
class SemanticDeduplicator:
|
||||
DEDUP_THRESHOLD = 0.88
|
||||
|
||||
def deduplicate(self, results, embeddings) -> list:
|
||||
"""消除冗余检索结果"""
|
||||
# 计算余弦相似度矩阵
|
||||
# 贪心去重
|
||||
...
|
||||
```
|
||||
|
||||
### R.4.2 Task R.4.2.1:语义分桶
|
||||
|
||||
```python
|
||||
class SemanticBucketing:
|
||||
async def bucket_by_topic(self, results, embeddings) -> dict:
|
||||
"""按主题自动组织检索结果"""
|
||||
# 使用聚类算法
|
||||
...
|
||||
```
|
||||
|
||||
### R.4.3 Task R.4.3.1:EPA 分析(探索)
|
||||
|
||||
```python
|
||||
class EPAModule:
|
||||
"""
|
||||
EPA: Embedding Projection Analysis
|
||||
高复杂度,Phase R.4 探索
|
||||
"""
|
||||
pass # 暂不实现
|
||||
```
|
||||
|
||||
### R.4.4 验收标准
|
||||
|
||||
- [ ] 语义去重测试通过
|
||||
- [ ] 语义分桶原型完成
|
||||
- [ ] EPA 分析方案设计完成(可选实现)
|
||||
|
||||
### R.4.5 工作量估算
|
||||
|
||||
| 任务 | 估算 |
|
||||
|------|------|
|
||||
| R.4.1.1 语义去重 | 1.5 天 |
|
||||
| R.4.2.1 语义分桶 | 2 天 |
|
||||
| R.4.3.1 EPA 设计 | 1 天 |
|
||||
| **R.4 总计(可选)** | **4.5 天** |
|
||||
|
||||
---
|
||||
|
||||
## R.5 阶段总结与产出
|
||||
|
||||
### R.5.1 完整实施路径
|
||||
|
||||
```
|
||||
R.0 ──────────────────────────────────────────────────────────────┐
|
||||
│ 现状与目标 │
|
||||
│ - 当前架构分析 │
|
||||
│ - 短板识别 │
|
||||
│ - VCPToolBox 借鉴点 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.1 ──────────────────────────────────────────────────────────────┐
|
||||
│ Token 感知分块优化 │
|
||||
│ - tiktoken 集成 │
|
||||
│ - 智能断句 │
|
||||
│ - 重叠分块 │
|
||||
│ │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.2 ──────────────────────────────────────────────────────────────┐
|
||||
│ 多索引架构 │
|
||||
│ - Collection 分离策略 │
|
||||
│ - 懒加载 + LRU TTL │
|
||||
│ - 重要性感知检索 │
|
||||
│ │
|
||||
│ 依赖: R.1 │
|
||||
│ 工作量: 4 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.3 ──────────────────────────────────────────────────────────────┐
|
||||
│ 动态权重增强 │
|
||||
│ - QueryAnalyzer │
|
||||
│ - DynamicReranker │
|
||||
│ - CoreTagAwareSearch │
|
||||
│ │
|
||||
│ 依赖: R.1 │
|
||||
│ 工作量: 4.5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
R.4 ──────────────────────────────────────────────────────────────┐
|
||||
│ 高级特性 (可选) │
|
||||
│ - 语义去重 │
|
||||
│ - 语义分桶 │
|
||||
│ - EPA 分析设计 │
|
||||
│ │
|
||||
│ 工作量: 4.5 天(可选) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### R.5.2 总工作量估算
|
||||
|
||||
| Phase | 工作量 |
|
||||
|-------|--------|
|
||||
| R.1 | 3 天 |
|
||||
| R.2 | 4 天 |
|
||||
| R.3 | 4.5 天 |
|
||||
| R.4(可选) | 4.5 天 |
|
||||
| **R.1-R.3 必须** | **11.5 天** |
|
||||
| **R.1-R.4 含可选** | **16 天** |
|
||||
|
||||
### R.5.3 产出清单
|
||||
|
||||
| 产出 | 对应 Phase |
|
||||
|------|-----------|
|
||||
| `services/chunker.py` | R.1 |
|
||||
| `services/multi_index.py` | R.2 |
|
||||
| `services/query_analyzer.py` | R.3 |
|
||||
| `services/dynamic_reranker.py` | R.3 |
|
||||
| `services/core_tag_search.py` | R.3 |
|
||||
| `models/document.py` 更新 | R.2, R.3 |
|
||||
| 单元测试 > 80% | R.1, R.2, R.3 |
|
||||
| 集成测试通过 | R.1, R.2, R.3 |
|
||||
|
||||
### R.5.4 与 Phase 1-5 的关系
|
||||
|
||||
| Phase | RAG 协作内容 |
|
||||
|-------|-------------|
|
||||
| Phase 1 | 基础加固:Task Schema 追踪 RAG 任务 |
|
||||
| Phase 2 | 协作:RAG 任务可分解给 Librarian Agent |
|
||||
| Phase 3 | 动态:支持多索引动态选择 |
|
||||
| Phase 4 | 可视化:RAG 检索过程可视化 |
|
||||
| Phase 5 | 高级:EPA 分析、语义分桶 |
|
||||
| **Phase R** | **独立 RAG 升级路径,可与 Phase 1-5 并行推进** |
|
||||
|
||||
---
|
||||
|
||||
## R.6 风险与注意事项
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| Token 计数不准确 | 低 | 使用 tiktoken 精确计数,多次验证 |
|
||||
| 索引分离后检索复杂 | 中 | 提供统一检索接口,隐藏内部逻辑 |
|
||||
| 动态权重调参困难 | 中 | 提供配置项,允许用户调整 |
|
||||
| EPA 实现复杂度高 | 高 | Phase R.4 可选,暂不实现 |
|
||||
173
development-doc/plan/tool-update/README.md
Normal file
173
development-doc/plan/tool-update/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Jarvis Tools 升级计划索引
|
||||
|
||||
本目录用于存放 Jarvis 工具系统的分阶段升级规划文档。
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `README.md` | 总览、阶段关系、实施顺序 |
|
||||
| `phase-t-0-current-state.md` | 当前现状、问题、目标架构、VCPToolBox 借鉴 |
|
||||
| `phase-t-1-manifest-system.md` | Manifest 驱动系统 |
|
||||
| `phase-t-2-tool-registry.md` | 工具注册中心 |
|
||||
| `phase-t-3-tool-implementation.md` | 核心工具实现 |
|
||||
| `phase-t-4-advanced.md` | 高级特性(多运行时/Agent协作) |
|
||||
| `checklist.md` | 执行清单 |
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先读 `phase-t-0-current-state.md`
|
||||
2. 再按顺序阅读 phase t-1 ~ t-4
|
||||
3. 实施时严格按阶段推进
|
||||
4. 参考 `checklist.md` 进行任务追踪
|
||||
|
||||
---
|
||||
|
||||
## 总体升级原则
|
||||
|
||||
1. **Manifest 驱动** - 声明式工具定义,热插拔
|
||||
2. **标准契约** - 统一的调用格式和返回结构
|
||||
3. **多运行时** - 支持 Python/JS/原生
|
||||
4. **类型安全** - Pydantic Schema 验证
|
||||
5. **可观测性** - 调用日志、耗时统计
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览图
|
||||
|
||||
```
|
||||
T.0 ──────────────────────────────────────────────────────────────┐
|
||||
│ 现状与目标 │
|
||||
│ - 当前工具系统分析 │
|
||||
│ - 短板识别 │
|
||||
│ - VCPToolBox 工具系统借鉴 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
T.1 ──────────────────────────────────────────────────────────────┐
|
||||
│ Manifest 驱动系统 │
|
||||
│ - 工具 manifest 定义 │
|
||||
│ - 标准化契约 │
|
||||
│ - Schema 验证 │
|
||||
│ │
|
||||
│ 核心文件: tools/manifests/, tools/schemas/ │
|
||||
│ 工作量: 3 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
T.2 ──────────────────────────────────────────────────────────────┐
|
||||
│ 工具注册中心 │
|
||||
│ - 工具发现机制 │
|
||||
│ - 动态注册 │
|
||||
│ - 工具描述生成 │
|
||||
│ │
|
||||
│ 核心文件: tools/registry.py │
|
||||
│ 依赖: T.1 │
|
||||
│ 工作量: 2 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
T.3 ──────────────────────────────────────────────────────────────┐
|
||||
│ 核心工具实现 │
|
||||
│ - 文件操作工具 │
|
||||
│ - 搜索工具 │
|
||||
│ - 网页抓取工具 │
|
||||
│ - 任务管理工具 │
|
||||
│ │
|
||||
│ 核心文件: tools/implementations/ │
|
||||
│ 依赖: T.2 │
|
||||
│ 工作量: 5 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
T.4 ──────────────────────────────────────────────────────────────┐
|
||||
│ 高级特性 │
|
||||
│ - 多运行时支持 │
|
||||
│ - Agent 间协作 │
|
||||
│ - 定时任务 │
|
||||
│ │
|
||||
│ 核心文件: tools/runtime/, agents/tools/ │
|
||||
│ 依赖: T.3 │
|
||||
│ 工作量: 4 天 │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VCPToolBox 工具系统核心借鉴
|
||||
|
||||
| 借鉴点 | 实现位置 | 难度 |
|
||||
|--------|---------|------|
|
||||
| Manifest 驱动 | T.1 | 🟢 低 |
|
||||
| 标准化契约 | T.1 | 🟢 低 |
|
||||
| configSchema 配置 | T.1 | 🟢 低 |
|
||||
| 工具注册中心 | T.2 | 🟡 中 |
|
||||
| 动态发现 | T.2 | 🟡 中 |
|
||||
| 文件操作工具 | T.3 | 🟢 低 |
|
||||
| 搜索工具 | T.3 | 🟡 中 |
|
||||
| 网页抓取 | T.3 | 🟡 中 |
|
||||
| 多运行时支持 | T.4 | 🟡 中 |
|
||||
| Agent 间协作 | T.4 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
T.0 → T.1 → T.2 → T.3 → T.4
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── 多运行时/Agent协作
|
||||
│ │ │ └── 核心工具
|
||||
│ │ └── 注册中心
|
||||
│ └── Manifest系统
|
||||
└── 现状与目标
|
||||
```
|
||||
|
||||
**注意:** T.1 是基础,后续阶段都依赖 T.1。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更追踪
|
||||
|
||||
| Phase | 新增文件 | 修改文件 |
|
||||
|-------|---------|---------|
|
||||
| T.1 | `tools/manifests/*.yaml`, `tools/schemas/` | `pyproject.toml` |
|
||||
| T.2 | `tools/registry.py`, `tools/base.py` | `services/agent_service.py` |
|
||||
| T.3 | `tools/implementations/*.py` | `tools/registry.py` |
|
||||
| T.4 | `tools/runtime/`, `agents/tools/` | `agents/graph.py` |
|
||||
|
||||
---
|
||||
|
||||
## 与 Agent Phase 1-5 的关系
|
||||
|
||||
| Agent Phase | Tools 协作内容 |
|
||||
|-------------|---------------|
|
||||
| Phase 1 | Task Schema 追踪工具调用 |
|
||||
| Phase 2 | 工具可委托给执行 Agent |
|
||||
| Phase 3 | 动态选择最优工具 |
|
||||
| Phase 4 | 工具调用可视化 |
|
||||
| Phase 5 | 多 Agent 工具协作 |
|
||||
| **Phase T** | **工具系统升级,与 Phase 1-5 协同** |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 注意事项 | 说明 |
|
||||
|---------|------|
|
||||
| T.1 是基础 | T.2-T.4 都依赖 T.1 的 Manifest 系统 |
|
||||
| 兼容性优先 | 保持现有 Agent 工具调用方式 |
|
||||
| 安全第一 | 严格权限控制,防止滥用 |
|
||||
| 测试优先 | 每个工具都要配套测试 |
|
||||
|
||||
---
|
||||
|
||||
## 总工作量
|
||||
|
||||
| Phase | 工作量 |
|
||||
|-------|--------|
|
||||
| T.1 | 3 天 |
|
||||
| T.2 | 2 天 |
|
||||
| T.3 | 5 天 |
|
||||
| T.4 | 4 天 |
|
||||
| **总计** | **14 天** |
|
||||
251
development-doc/plan/tool-update/checklist.md
Normal file
251
development-doc/plan/tool-update/checklist.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Tools 升级执行清单
|
||||
|
||||
本清单用于追踪 Tools 升级计划的执行进度。
|
||||
|
||||
---
|
||||
|
||||
## 总进度
|
||||
|
||||
| Phase | 名称 | 状态 | 工作量 |
|
||||
|-------|------|------|--------|
|
||||
| T.0 | 现状与目标 | ✅ 完成 | - |
|
||||
| T.1 | Manifest 驱动系统 | ⬜ 待开始 | 3 天 |
|
||||
| T.2 | 工具注册中心 | ⬜ 待开始 | 2 天 |
|
||||
| T.3 | 核心工具实现 | ⬜ 待开始 | 5 天 |
|
||||
| T.4 | 高级特性 | ⬜ 待开始 | 4 天 |
|
||||
| **总计** | | | **14 天** |
|
||||
|
||||
---
|
||||
|
||||
## Phase T.1:Manifest 驱动系统
|
||||
|
||||
### 目标
|
||||
建立 Jarvis 的 Manifest 驱动工具系统,定义标准化工件声明。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### Schema 定义
|
||||
- [ ] 创建 `tools/schemas/manifest.py`
|
||||
- [ ] 定义 `ToolManifest` Schema
|
||||
- [ ] 定义 `ToolType` 枚举
|
||||
- [ ] 定义 `RuntimeType` 枚举
|
||||
- [ ] 定义 `InvocationCommand` Schema
|
||||
- [ ] 创建 `tools/schemas/tool_call.py`
|
||||
- [ ] 定义 `ToolCallRequest` Schema
|
||||
- [ ] 定义 `ToolCallResponse` Schema
|
||||
- [ ] 定义 `ToolExecutionLog` Schema
|
||||
|
||||
#### 验证器
|
||||
- [ ] 创建 `tools/schemas/validator.py`
|
||||
- [ ] 实现 `validate_manifest` 函数
|
||||
- [ ] 实现 `validate_tool_call` 函数
|
||||
- [ ] 实现错误类
|
||||
|
||||
#### 配置系统
|
||||
- [ ] 创建 `tools/configs/loader.py`
|
||||
- [ ] 实现 `ConfigLoader` 类
|
||||
- [ ] 实现配置缓存
|
||||
- [ ] 实现配置重载
|
||||
|
||||
#### Manifest 文件
|
||||
- [ ] 创建 `tools/manifests/file_operator.yaml`
|
||||
- [ ] 创建 `tools/manifests/web_search.yaml`
|
||||
- [ ] 创建其他工具 Manifest
|
||||
|
||||
#### 测试
|
||||
- [ ] 单元测试
|
||||
|
||||
### 产出文件
|
||||
- `tools/schemas/manifest.py`
|
||||
- `tools/schemas/tool_call.py`
|
||||
- `tools/schemas/validator.py`
|
||||
- `tools/configs/loader.py`
|
||||
- `tools/manifests/*.yaml`
|
||||
|
||||
### 验收
|
||||
- [ ] Schema 验证正常工作
|
||||
- [ ] 配置加载器正常工作
|
||||
- [ ] Manifest 文件格式正确
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase T.2:工具注册中心
|
||||
|
||||
### 目标
|
||||
实现工具的动态注册、发现和管理。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 注册中心
|
||||
- [ ] 创建 `tools/registry.py`
|
||||
- [ ] 实现 `ToolMetadata` dataclass
|
||||
- [ ] 实现 `ToolRegistry` 类
|
||||
- [ ] 实现注册/注销方法
|
||||
- [ ] 实现查询方法
|
||||
- [ ] 实现统计方法
|
||||
|
||||
#### 工具发现
|
||||
- [ ] 创建 `tools/discovery.py`
|
||||
- [ ] 实现 `ToolDiscovery` 类
|
||||
- [ ] 实现自动发现
|
||||
- [ ] 实现热重载
|
||||
|
||||
#### 描述生成
|
||||
- [ ] 创建 `tools/description.py`
|
||||
- [ ] 实现 AI 友好描述生成
|
||||
- [ ] 实现工具列表生成
|
||||
|
||||
#### 权限控制
|
||||
- [ ] 创建 `tools/permissions.py`
|
||||
- [ ] 实现 `ToolPermission` 枚举
|
||||
- [ ] 实现 `ToolPermissionChecker` 类
|
||||
|
||||
#### LangChain 集成
|
||||
- [ ] 创建 `tools/langchain_adapter.py`
|
||||
- [ ] 实现适配器
|
||||
- [ ] 集成到 Agent
|
||||
|
||||
#### 测试
|
||||
- [ ] 单元测试
|
||||
|
||||
### 产出文件
|
||||
- `tools/registry.py`
|
||||
- `tools/discovery.py`
|
||||
- `tools/description.py`
|
||||
- `tools/permissions.py`
|
||||
- `tools/langchain_adapter.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 注册中心正常工作
|
||||
- [ ] 工具发现正常工作
|
||||
- [ ] 权限检查正常工作
|
||||
- [ ] LangChain 适配器正常工作
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase T.3:核心工具实现
|
||||
|
||||
### 目标
|
||||
实现文件操作、搜索、网页抓取等核心工具。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 文件操作工具
|
||||
- [ ] 创建 `tools/implementations/file_operator.py`
|
||||
- [ ] 实现 `FileOperator` 类
|
||||
- [ ] 实现 read_file
|
||||
- [ ] 实现 write_file
|
||||
- [ ] 实现 list_directory
|
||||
- [ ] 实现 search_files
|
||||
- [ ] 实现路径安全检查
|
||||
- [ ] 实现多格式支持(PDF/DOCX/XLSX)
|
||||
|
||||
#### 搜索工具
|
||||
- [ ] 创建 `tools/implementations/web_search.py`
|
||||
- [ ] 实现 `WebSearch` 类
|
||||
- [ ] 实现 search 方法
|
||||
- [ ] 实现 deep_search 方法
|
||||
|
||||
#### 网页抓取工具
|
||||
- [ ] 创建 `tools/implementations/web_fetch.py`
|
||||
- [ ] 实现 `WebFetch` 类
|
||||
- [ ] 实现 fetch 方法
|
||||
- [ ] 实现 screenshot 方法
|
||||
|
||||
#### 任务管理工具
|
||||
- [ ] 创建 `tools/implementations/task_manager.py`
|
||||
- [ ] 实现 `TaskManager` 类
|
||||
- [ ] 实现任务 CRUD
|
||||
|
||||
#### Manifest 绑定
|
||||
- [ ] 更新 Manifest 文件
|
||||
- [ ] 注册到工具中心
|
||||
|
||||
#### 测试
|
||||
- [ ] 单元测试
|
||||
|
||||
### 产出文件
|
||||
- `tools/implementations/file_operator.py`
|
||||
- `tools/implementations/web_search.py`
|
||||
- `tools/implementations/web_fetch.py`
|
||||
- `tools/implementations/task_manager.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 文件操作工具正常工作
|
||||
- [ ] 搜索工具正常工作
|
||||
- [ ] 网页抓取工具正常工作
|
||||
- [ ] 任务管理工具正常工作
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## Phase T.4:高级特性
|
||||
|
||||
### 目标
|
||||
实现多运行时支持、Agent 协作和定时任务。
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 运行时系统
|
||||
- [ ] 创建 `tools/runtime/base.py`
|
||||
- [ ] 定义 `BaseRuntime` 抽象基类
|
||||
- [ ] 创建 `tools/runtime/python_runtime.py`
|
||||
- [ ] 创建 `tools/runtime/js_runtime.py`
|
||||
- [ ] 创建 `tools/runtime/native_runtime.py`
|
||||
- [ ] 创建 `tools/runtime/manager.py`
|
||||
|
||||
#### Agent 协作
|
||||
- [ ] 创建 `agents/tools/collaboration.py`
|
||||
- [ ] 定义 `CollaborationMessage`
|
||||
- [ ] 实现 `CollaborationProtocol` 类
|
||||
- [ ] 实现请求/响应机制
|
||||
|
||||
#### 定时任务
|
||||
- [ ] 创建 `tools/scheduler.py`
|
||||
- [ ] 实现 `ScheduledTask`
|
||||
- [ ] 实现 `ToolScheduler` 类
|
||||
- [ ] 实现多种调度类型
|
||||
|
||||
#### 测试
|
||||
- [ ] 单元测试
|
||||
|
||||
### 产出文件
|
||||
- `tools/runtime/*.py`
|
||||
- `agents/tools/collaboration.py`
|
||||
- `tools/scheduler.py`
|
||||
|
||||
### 验收
|
||||
- [ ] 多运行时正常工作
|
||||
- [ ] Agent 协作正常工作
|
||||
- [ ] 定时任务正常工作
|
||||
- [ ] 单元测试通过
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] 所有 Phase T.1-T.4 任务完成
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] API 文档更新完成
|
||||
- [ ] 部署验证通过
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 多运行时复杂性 | 开发成本增加 | 优先实现 Python 运行时 |
|
||||
| JS 运行时依赖 Node | 部署环境要求 | 提供降级方案 |
|
||||
| 定时任务精度 | 任务延迟 | 使用专业调度库 |
|
||||
| 安全漏洞 | 系统风险 | 严格权限控制 |
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 日期 | Phase | 变更内容 |
|
||||
|------|-------|----------|
|
||||
| 2026-04-04 | T.0 | 创建文档 |
|
||||
192
development-doc/plan/tool-update/phase-t-0-current-state.md
Normal file
192
development-doc/plan/tool-update/phase-t-0-current-state.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Phase T.0:Tools 现状与目标
|
||||
|
||||
日期:2026-04-04
|
||||
状态:已完成
|
||||
借鉴来源:VCPToolBox Plugin 系统
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
本文件用于统一背景认知,明确:
|
||||
|
||||
- Jarvis 当前工具系统处于什么水平
|
||||
- 主要短板是什么
|
||||
- 为什么要升级
|
||||
- 升级后的目标形态是什么
|
||||
- VCPToolBox 给我们什么启发
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前 Jarvis Tools 架构
|
||||
|
||||
### 2.1 核心流程
|
||||
|
||||
```
|
||||
Agent 决策 → 工具调用 → LLM API → 返回结果
|
||||
```
|
||||
|
||||
### 2.2 当前工具实现
|
||||
|
||||
Jarvis 当前的工具以 LangChain Tools 形式存在:
|
||||
|
||||
```python
|
||||
# agents/tools/
|
||||
├── base.py # 工具基类
|
||||
├── file_tools.py # 文件操作
|
||||
├── search_tools.py # 搜索
|
||||
└── web_tools.py # 网页相关
|
||||
```
|
||||
|
||||
### 2.3 当前工具列表
|
||||
|
||||
| 工具 | 功能 | 实现方式 |
|
||||
|------|------|---------|
|
||||
| `read_file` | 读取文件 | Python |
|
||||
| `write_file` | 写入文件 | Python |
|
||||
| `run_python` | 执行代码 | Python |
|
||||
| `search_knowledge` | 知识库检索 | LangChain |
|
||||
| `search_web` | 联网搜索 | API |
|
||||
|
||||
### 2.4 当前问题
|
||||
|
||||
| 问题 | 影响 |
|
||||
|------|------|
|
||||
| 硬编码工具 | 新增工具需改代码 |
|
||||
| 无 manifest | 无法热插拔 |
|
||||
| 无标准化契约 | 调用格式不统一 |
|
||||
| 无配置分离 | 敏感信息易泄露 |
|
||||
| 无类型安全 | 验证缺失 |
|
||||
| 无权限控制 | 安全隐患 |
|
||||
|
||||
---
|
||||
|
||||
## 3. VCPToolBox 工具系统分析
|
||||
|
||||
### 3.1 六大插件类型
|
||||
|
||||
```javascript
|
||||
const PLUGIN_TYPES = {
|
||||
static: "静态占位符,自动注入系统提示词",
|
||||
synchronous: "同步执行,stdio 协议",
|
||||
asynchronous: "异步执行,后台处理",
|
||||
service: "持续运行服务",
|
||||
hybridservice: "混合服务(Agent间通讯)",
|
||||
messagePreprocessor: "消息预处理"
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Manifest 标准契约
|
||||
|
||||
```javascript
|
||||
{
|
||||
"manifestVersion": "1.0.0",
|
||||
"name": "PluginName",
|
||||
"displayName": "中文显示名",
|
||||
"description": "功能描述",
|
||||
"pluginType": "synchronous",
|
||||
"version": "1.0.0",
|
||||
|
||||
"entryPoint": {
|
||||
"type": "nodejs",
|
||||
"command": "node Plugin.js",
|
||||
"timeout": 300000
|
||||
},
|
||||
|
||||
"communication": {
|
||||
"protocol": "stdio",
|
||||
"timeout": 300000
|
||||
},
|
||||
|
||||
"configSchema": {
|
||||
"API_KEY": {
|
||||
"type": "string",
|
||||
"description": "API 密钥"
|
||||
}
|
||||
},
|
||||
|
||||
"capabilities": {
|
||||
"invocationCommands": [
|
||||
{
|
||||
"commandIdentifier": "CommandName",
|
||||
"description": "详细描述+调用格式示例",
|
||||
"example": "调用示例"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 核心设计理念
|
||||
|
||||
1. **声明式** - 工具通过 manifest 声明,框架自动发现
|
||||
2. **标准化** - 统一的调用协议(stdio/websocket)
|
||||
3. **可配置** - configSchema 声明配置项
|
||||
4. **可观测** - 调用日志、超时控制
|
||||
5. **安全隔离** - 沙箱执行、权限控制
|
||||
|
||||
### 3.4 关键文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `Plugin.js` | 插件加载与执行引擎 |
|
||||
| `plugin-manifest.json` | 插件声明契约 |
|
||||
| `config.env` | 插件配置 |
|
||||
| `routes/` | API 路由层 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 目标架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Tool Manifests │
|
||||
│ - YAML/JSON 声明式定义 │
|
||||
│ - 版本管理 │
|
||||
│ - Schema 验证 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Tool Registry │
|
||||
│ - 动态发现 │
|
||||
│ - 权限控制 │
|
||||
│ - 调用统计 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Tool Executor │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Python RT │ │ JS RT │ │ Native RT │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Tool Output │
|
||||
│ - 类型化返回 │
|
||||
│ - 错误处理 │
|
||||
│ - 调用日志 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 借鉴点映射
|
||||
|
||||
| VCPToolBox 借鉴点 | Jarvis 实现位置 | 优先级 |
|
||||
|-------------------|---------------|--------|
|
||||
| Manifest 驱动 | `tools/manifests/` | 🟢 高 |
|
||||
| 标准化契约 | `tools/schemas/` | 🟢 高 |
|
||||
| configSchema | `tools/base.py` | 🟢 高 |
|
||||
| 工具注册中心 | `tools/registry.py` | 🟡 中 |
|
||||
| 动态发现 | `tools/discovery.py` | 🟡 中 |
|
||||
| 调用日志 | `tools/logging.py` | 🟡 中 |
|
||||
| 超时控制 | `tools/executor.py` | 🟡 中 |
|
||||
| 多运行时 | `tools/runtime/` | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 本阶段产出要求
|
||||
|
||||
- [x] 团队对 Jarvis 当前工具系统和目标方向达成一致
|
||||
- [x] VCPToolBox 工具系统借鉴点已映射到具体 Phase
|
||||
- [x] 后续 phase 文档能够在这个认知基础上展开
|
||||
484
development-doc/plan/tool-update/phase-t-1-manifest-system.md
Normal file
484
development-doc/plan/tool-update/phase-t-1-manifest-system.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# Phase T.1:Manifest 驱动系统
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:T.0(已完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
建立 Jarvis 的 Manifest 驱动工具系统:
|
||||
|
||||
- 定义工具 Manifest Schema
|
||||
- 实现 Schema 验证
|
||||
- 创建核心工具的 Manifest 文件
|
||||
- 实现配置分离
|
||||
|
||||
---
|
||||
|
||||
## 2. Manifest Schema 设计
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
backend/app/tools/
|
||||
├── manifests/ # 工具 manifest
|
||||
│ ├── file_operator.yaml
|
||||
│ ├── search.yaml
|
||||
│ └── web_fetch.yaml
|
||||
├── schemas/ # Schema 定义
|
||||
│ ├── __init__.py
|
||||
│ ├── manifest.py # Manifest Schema
|
||||
│ ├── tool_call.py # 工具调用 Schema
|
||||
│ └── config.py # 配置 Schema
|
||||
└── configs/ # 配置分离
|
||||
└── .tool.example # 工具配置模板
|
||||
```
|
||||
|
||||
### 2.2 ToolManifest Schema
|
||||
|
||||
```python
|
||||
# tools/schemas/manifest.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ToolType(str, Enum):
|
||||
"""工具类型"""
|
||||
SYNC = "sync" # 同步执行
|
||||
ASYNC = "async" # 异步执行
|
||||
SERVICE = "service" # 持续服务
|
||||
|
||||
|
||||
class RuntimeType(str, Enum):
|
||||
"""运行时类型"""
|
||||
PYTHON = "python"
|
||||
JAVASCRIPT = "javascript"
|
||||
NATIVE = "native"
|
||||
|
||||
|
||||
class InvocationCommand(BaseModel):
|
||||
"""调用命令定义"""
|
||||
name: str = Field(..., description="命令名称")
|
||||
description: str = Field(..., description="命令描述(给 AI 看)")
|
||||
parameters: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="参数 JSON Schema"
|
||||
)
|
||||
required: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="必需参数列表"
|
||||
)
|
||||
example: Optional[str] = Field(
|
||||
default=None,
|
||||
description="调用示例"
|
||||
)
|
||||
|
||||
|
||||
class ToolManifest(BaseModel):
|
||||
"""工具 Manifest"""
|
||||
manifest_version: str = Field(
|
||||
default="1.0.0",
|
||||
description="Manifest 版本"
|
||||
)
|
||||
name: str = Field(..., description="工具名称(英文,唯一)")
|
||||
display_name: str = Field(..., description="显示名称(中文)")
|
||||
description: str = Field(..., description="工具描述")
|
||||
author: Optional[str] = Field(default=None, description="作者")
|
||||
version: str = Field(default="1.0.0", description="版本号")
|
||||
|
||||
# 执行配置
|
||||
type: ToolType = Field(default=ToolType.SYNC, description="工具类型")
|
||||
runtime: RuntimeType = Field(default=RuntimeType.PYTHON, description="运行时")
|
||||
entry: str = Field(..., description="执行入口(文件路径或命令)")
|
||||
timeout: int = Field(default=30000, description="超时时间(毫秒)")
|
||||
|
||||
# 配置
|
||||
config_schema: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="配置项 Schema"
|
||||
)
|
||||
|
||||
# 能力
|
||||
commands: List[InvocationCommand] = Field(
|
||||
default_factory=list,
|
||||
description="可用命令列表"
|
||||
)
|
||||
|
||||
# 元数据
|
||||
tags: Optional[List[str]] = Field(default=None, description="标签")
|
||||
dependencies: Optional[List[str]] = Field(default=None, description="依赖工具")
|
||||
enabled: bool = Field(default=True, description="是否启用")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
```
|
||||
|
||||
### 2.3 ToolCall Schema
|
||||
|
||||
```python
|
||||
# tools/schemas/tool_call.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""工具调用请求"""
|
||||
tool_name: str = Field(..., description="工具名称")
|
||||
command: str = Field(..., description="命令名称")
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict, description="参数")
|
||||
timeout: Optional[int] = Field(default=None, description="超时时间")
|
||||
context: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="上下文信息"
|
||||
)
|
||||
|
||||
|
||||
class ToolCallResponse(BaseModel):
|
||||
"""工具调用响应"""
|
||||
status: str = Field(..., description="状态: success/error")
|
||||
result: Optional[Any] = Field(default=None, description="执行结果")
|
||||
error: Optional[str] = Field(default=None, description="错误信息")
|
||||
message: Optional[str] = Field(default=None, description="AI 友好消息")
|
||||
base64: Optional[str] = Field(default=None, description="Base64 数据")
|
||||
duration_ms: Optional[int] = Field(default=None, description="执行耗时")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class ToolExecutionLog(BaseModel):
|
||||
"""工具执行日志"""
|
||||
id: str
|
||||
tool_name: str
|
||||
command: str
|
||||
parameters: Dict[str, Any]
|
||||
status: str
|
||||
duration_ms: int
|
||||
error: Optional[str]
|
||||
user_id: Optional[str]
|
||||
agent_id: Optional[str]
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Manifest 示例
|
||||
|
||||
### 3.1 file_operator.yaml
|
||||
|
||||
```yaml
|
||||
manifest_version: "1.0.0"
|
||||
name: file_operator
|
||||
display_name: 文件操作器
|
||||
description: 强大的文件系统操作工具,支持读写、搜索、下载等功能
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/file_operator.py
|
||||
timeout: 30000
|
||||
|
||||
config_schema:
|
||||
allowed_directories:
|
||||
type: string
|
||||
description: 允许操作的目录列表,逗号分隔
|
||||
default: ""
|
||||
max_file_size:
|
||||
type: integer
|
||||
description: 最大文件大小(字节)
|
||||
default: 10485760
|
||||
|
||||
commands:
|
||||
- name: read_file
|
||||
description: |
|
||||
读取指定路径文件的内容。支持 PDF、DOCX、XLSX 等格式自动解析。
|
||||
参数:
|
||||
- filePath (必需): 文件绝对路径
|
||||
- encoding (可选): 编码格式,默认 utf8
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
description: 文件绝对路径
|
||||
encoding:
|
||||
type: string
|
||||
default: utf8
|
||||
required: [filePath]
|
||||
|
||||
- name: write_file
|
||||
description: |
|
||||
将内容写入文件。如果文件存在,自动创建新文件避免覆盖。
|
||||
参数:
|
||||
- filePath (必需): 文件绝对路径
|
||||
- content (必需): 文件内容
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
required: [filePath, content]
|
||||
|
||||
- name: list_directory
|
||||
description: |
|
||||
列出目录内容。
|
||||
参数:
|
||||
- directoryPath (必需): 目录绝对路径
|
||||
- showHidden (可选): 是否显示隐藏文件
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
directoryPath:
|
||||
type: string
|
||||
showHidden:
|
||||
type: boolean
|
||||
default: false
|
||||
required: [directoryPath]
|
||||
|
||||
- name: search_files
|
||||
description: |
|
||||
递归搜索匹配模式的文件。
|
||||
参数:
|
||||
- searchPath (必需): 搜索起始目录
|
||||
- pattern (必需): 文件模式,如 *.txt
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
searchPath:
|
||||
type: string
|
||||
pattern:
|
||||
type: string
|
||||
required: [searchPath, pattern]
|
||||
|
||||
tags: [file, system, essential]
|
||||
enabled: true
|
||||
```
|
||||
|
||||
### 3.2 search.yaml
|
||||
|
||||
```yaml
|
||||
manifest_version: "1.0.0"
|
||||
name: web_search
|
||||
display_name: 联网搜索
|
||||
description: 语义级并发搜索引擎,支持多源搜索和结果聚合
|
||||
author: Jarvis
|
||||
version: "1.0.0"
|
||||
|
||||
type: sync
|
||||
runtime: python
|
||||
entry: tools/implementations/web_search.py
|
||||
timeout: 60000
|
||||
|
||||
config_schema:
|
||||
api_key:
|
||||
type: string
|
||||
description: 搜索引擎 API 密钥
|
||||
required: true
|
||||
max_results:
|
||||
type: integer
|
||||
description: 最大返回结果数
|
||||
default: 10
|
||||
|
||||
commands:
|
||||
- name: search
|
||||
description: |
|
||||
执行语义级搜索。
|
||||
参数:
|
||||
- query (必需): 搜索关键词
|
||||
- max_results (可选): 最大结果数
|
||||
- sources (可选): 搜索源列表
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
max_results:
|
||||
type: integer
|
||||
default: 10
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [query]
|
||||
|
||||
- name: deep_search
|
||||
description: |
|
||||
深度搜索,带摘要生成。
|
||||
参数:
|
||||
- query (必需): 研究主题
|
||||
- keywords (必需): 关键词列表
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
keywords:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [query, keywords]
|
||||
|
||||
tags: [search, web, research]
|
||||
enabled: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema 验证
|
||||
|
||||
### 4.1 验证器
|
||||
|
||||
```python
|
||||
# tools/schemas/validator.py
|
||||
from pydantic import ValidationError
|
||||
from tools.schemas.manifest import ToolManifest
|
||||
|
||||
|
||||
def validate_manifest(data: dict) -> ToolManifest:
|
||||
"""验证 Manifest 数据"""
|
||||
try:
|
||||
return ToolManifest(**data)
|
||||
except ValidationError as e:
|
||||
raise ManifestValidationError(str(e))
|
||||
|
||||
|
||||
def validate_tool_call(data: dict) -> ToolCallRequest:
|
||||
"""验证工具调用请求"""
|
||||
from tools.schemas.tool_call import ToolCallRequest
|
||||
try:
|
||||
return ToolCallRequest(**data)
|
||||
except ValidationError as e:
|
||||
raise ToolCallValidationError(str(e))
|
||||
|
||||
|
||||
class ManifestValidationError(Exception):
|
||||
"""Manifest 验证错误"""
|
||||
pass
|
||||
|
||||
|
||||
class ToolCallValidationError(Exception):
|
||||
"""工具调用验证错误"""
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置分离
|
||||
|
||||
### 5.1 工具配置模板
|
||||
|
||||
```yaml
|
||||
# tools/configs/.tool.example
|
||||
# 文件操作器
|
||||
file_operator:
|
||||
allowed_directories: ""
|
||||
max_file_size: 10485760
|
||||
|
||||
# 联网搜索
|
||||
web_search:
|
||||
api_key: ""
|
||||
max_results: 10
|
||||
```
|
||||
|
||||
### 5.2 配置加载器
|
||||
|
||||
```python
|
||||
# tools/configs/loader.py
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""工具配置加载器"""
|
||||
|
||||
def __init__(self, config_dir: Path):
|
||||
self.config_dir = config_dir
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def load(self, tool_name: str) -> Dict[str, Any]:
|
||||
"""加载指定工具的配置"""
|
||||
if tool_name in self._cache:
|
||||
return self._cache[tool_name]
|
||||
|
||||
config_file = self.config_dir / f"{tool_name}.yaml"
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
|
||||
with open(config_file) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
self._cache[tool_name] = config
|
||||
return config
|
||||
|
||||
def reload(self, tool_name: str) -> Dict[str, Any]:
|
||||
"""重新加载配置"""
|
||||
if tool_name in self._cache:
|
||||
del self._cache[tool_name]
|
||||
return self.load(tool_name)
|
||||
|
||||
def get(self, tool_name: str, key: str, default: Any = None) -> Any:
|
||||
"""获取配置项"""
|
||||
config = self.load(tool_name)
|
||||
return config.get(key, default)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 创建目录结构 | 🟢 高 |
|
||||
| 2 | 实现 ToolManifest Schema | 🟢 高 |
|
||||
| 3 | 实现 ToolCall Schema | 🟢 高 |
|
||||
| 4 | 实现 Schema 验证器 | 🟢 高 |
|
||||
| 5 | 创建配置加载器 | 🟢 高 |
|
||||
| 6 | 创建 file_operator.yaml | 🟢 高 |
|
||||
| 7 | 创建 search.yaml | 🟡 中 |
|
||||
| 8 | 创建其他工具 Manifest | 🟡 中 |
|
||||
| 9 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `tools/__init__.py` | 模块初始化 |
|
||||
| `tools/schemas/__init__.py` | Schema 导出 |
|
||||
| `tools/schemas/manifest.py` | 新增 |
|
||||
| `tools/schemas/tool_call.py` | 新增 |
|
||||
| `tools/schemas/validator.py` | 新增 |
|
||||
| `tools/configs/loader.py` | 新增 |
|
||||
| `tools/manifests/file_operator.yaml` | 新增 |
|
||||
| `tools/manifests/search.yaml` | 新增 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| Schema 定义 | 1 天 |
|
||||
| 验证器 | 0.5 天 |
|
||||
| 配置加载器 | 0.5 天 |
|
||||
| Manifest 文件 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **3 天** |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- [ ] ToolManifest Schema 可正确验证 Manifest
|
||||
- [ ] ToolCall Schema 可正确验证调用请求
|
||||
- [ ] 配置加载器可正确加载配置
|
||||
- [ ] Manifest 文件格式正确
|
||||
- [ ] Schema 验证器可捕获错误
|
||||
- [ ] 单元测试覆盖核心逻辑
|
||||
476
development-doc/plan/tool-update/phase-t-2-tool-registry.md
Normal file
476
development-doc/plan/tool-update/phase-t-2-tool-registry.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Phase T.2:工具注册中心
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:T.1(待完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
建立 Jarvis 的工具注册中心:
|
||||
|
||||
- 工具动态发现
|
||||
- 工具注册与管理
|
||||
- 工具描述生成
|
||||
- 调用统计与监控
|
||||
|
||||
---
|
||||
|
||||
## 2. 工具注册中心架构
|
||||
|
||||
### 2.1 核心类
|
||||
|
||||
```python
|
||||
# tools/registry.py
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolMetadata:
|
||||
"""工具元数据"""
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
version: str
|
||||
author: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
registered_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
# 统计
|
||||
call_count: int = 0
|
||||
error_count: int = 0
|
||||
total_duration_ms: int = 0
|
||||
|
||||
@property
|
||||
def avg_duration_ms(self) -> int:
|
||||
if self.call_count == 0:
|
||||
return 0
|
||||
return self.total_duration_ms // self.call_count
|
||||
|
||||
@property
|
||||
def error_rate(self) -> float:
|
||||
if self.call_count == 0:
|
||||
return 0.0
|
||||
return self.error_count / self.call_count
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""工具注册中心"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, ToolMetadata] = {}
|
||||
self._executors: Dict[str, Callable] = {}
|
||||
self._configs: Dict[str, dict] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# === 注册方法 ===
|
||||
|
||||
async def register(
|
||||
self,
|
||||
manifest_path: str,
|
||||
executor: Callable,
|
||||
config: Optional[dict] = None,
|
||||
) -> ToolMetadata:
|
||||
"""注册工具"""
|
||||
from tools.schemas.validator import validate_manifest
|
||||
import yaml
|
||||
|
||||
with open(manifest_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
manifest = validate_manifest(data)
|
||||
|
||||
metadata = ToolMetadata(
|
||||
name=manifest.name,
|
||||
display_name=manifest.display_name,
|
||||
description=manifest.description,
|
||||
version=manifest.version,
|
||||
author=manifest.author,
|
||||
tags=manifest.tags or [],
|
||||
dependencies=manifest.dependencies or [],
|
||||
enabled=manifest.enabled,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self._tools[manifest.name] = metadata
|
||||
self._executors[manifest.name] = executor
|
||||
if config:
|
||||
self._configs[manifest.name] = config
|
||||
|
||||
return metadata
|
||||
|
||||
async def unregister(self, name: str) -> bool:
|
||||
"""注销工具"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
del self._tools[name]
|
||||
del self._executors[name]
|
||||
self._configs.pop(name, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def enable(self, name: str) -> None:
|
||||
"""启用工具"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
self._tools[name].enabled = True
|
||||
|
||||
async def disable(self, name: str) -> None:
|
||||
"""禁用工具"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
self._tools[name].enabled = False
|
||||
|
||||
# === 查询方法 ===
|
||||
|
||||
async def get(self, name: str) -> Optional[ToolMetadata]:
|
||||
"""获取工具元数据"""
|
||||
return self._tools.get(name)
|
||||
|
||||
async def get_executor(self, name: str) -> Optional[Callable]:
|
||||
"""获取工具执行器"""
|
||||
return self._executors.get(name)
|
||||
|
||||
async def get_config(self, name: str) -> dict:
|
||||
"""获取工具配置"""
|
||||
return self._configs.get(name, {})
|
||||
|
||||
async def list_all(self) -> List[ToolMetadata]:
|
||||
"""列出所有工具"""
|
||||
return list(self._tools.values())
|
||||
|
||||
async def list_enabled(self) -> List[ToolMetadata]:
|
||||
"""列出已启用的工具"""
|
||||
return [t for t in self._tools.values() if t.enabled]
|
||||
|
||||
async def list_by_tag(self, tag: str) -> List[ToolMetadata]:
|
||||
"""按标签筛选工具"""
|
||||
return [t for t in self._tools.values() if tag in t.tags]
|
||||
|
||||
async def search(self, query: str) -> List[ToolMetadata]:
|
||||
"""搜索工具"""
|
||||
query = query.lower()
|
||||
return [
|
||||
t for t in self._tools.values()
|
||||
if query in t.name.lower()
|
||||
or query in t.description.lower()
|
||||
or query in t.display_name.lower()
|
||||
]
|
||||
|
||||
# === 统计方法 ===
|
||||
|
||||
async def record_call(
|
||||
self,
|
||||
name: str,
|
||||
duration_ms: int,
|
||||
error: bool = False,
|
||||
) -> None:
|
||||
"""记录调用"""
|
||||
async with self._lock:
|
||||
if name in self._tools:
|
||||
tool = self._tools[name]
|
||||
tool.call_count += 1
|
||||
tool.total_duration_ms += duration_ms
|
||||
if error:
|
||||
tool.error_count += 1
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""获取统计信息"""
|
||||
tools = list(self._tools.values())
|
||||
return {
|
||||
"total_tools": len(tools),
|
||||
"enabled_tools": sum(1 for t in tools if t.enabled),
|
||||
"total_calls": sum(t.call_count for t in tools),
|
||||
"total_errors": sum(t.error_count for t in tools),
|
||||
"avg_error_rate": sum(t.error_rate for t in tools) / len(tools) if tools else 0,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 工具发现机制
|
||||
|
||||
### 3.1 自动发现
|
||||
|
||||
```python
|
||||
# tools/discovery.py
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
class ToolDiscovery:
|
||||
"""工具自动发现"""
|
||||
|
||||
def __init__(self, manifest_dir: Path):
|
||||
self.manifest_dir = manifest_dir
|
||||
|
||||
def discover(self) -> List[Path]:
|
||||
"""发现所有 Manifest 文件"""
|
||||
manifests = list(self.manifest_dir.glob("**/*.yaml"))
|
||||
manifests.extend(self.manifest_dir.glob("**/*.yml"))
|
||||
manifests.extend(self.manifest_dir.glob("**/*.json"))
|
||||
return manifests
|
||||
|
||||
def discover_by_tag(self, tag: str) -> List[Path]:
|
||||
"""按标签发现"""
|
||||
# 读取所有 manifest,筛选标签
|
||||
pass
|
||||
|
||||
async def hot_reload(self, registry: ToolRegistry) -> None:
|
||||
"""热重载工具"""
|
||||
for manifest_path in self.discover():
|
||||
# 重新注册
|
||||
pass
|
||||
```
|
||||
|
||||
### 3.2 启动时注册
|
||||
|
||||
```python
|
||||
# tools/loader.py
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def load_all_tools(registry: ToolRegistry) -> None:
|
||||
"""加载所有工具"""
|
||||
manifest_dir = Path(__file__).parent / "manifests"
|
||||
|
||||
discovery = ToolDiscovery(manifest_dir)
|
||||
|
||||
for manifest_path in discovery.discover():
|
||||
tool_name = manifest_path.stem
|
||||
|
||||
# 加载 executor
|
||||
executor = load_executor(manifest_path)
|
||||
|
||||
# 加载配置
|
||||
config = load_config(tool_name)
|
||||
|
||||
# 注册
|
||||
await registry.register(manifest_path, executor, config)
|
||||
|
||||
|
||||
def load_executor(manifest_path: Path) -> Callable:
|
||||
"""加载工具执行器"""
|
||||
import yaml
|
||||
|
||||
with open(manifest_path) as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
|
||||
# 根据运行时类型加载
|
||||
runtime = manifest.get("runtime", "python")
|
||||
|
||||
if runtime == "python":
|
||||
return load_python_executor(manifest)
|
||||
elif runtime == "javascript":
|
||||
return load_js_executor(manifest)
|
||||
else:
|
||||
return load_native_executor(manifest)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工具描述生成
|
||||
|
||||
### 4.1 AI 友好的工具描述
|
||||
|
||||
```python
|
||||
# tools/description.py
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def generate_tool_description(manifest: dict) -> str:
|
||||
"""生成 AI 友好的工具描述"""
|
||||
lines = [
|
||||
f"## {manifest['display_name']}",
|
||||
f"{manifest['description']}",
|
||||
"",
|
||||
"### 可用命令:",
|
||||
]
|
||||
|
||||
for cmd in manifest.get("commands", []):
|
||||
lines.append(f"#### {cmd['name']}")
|
||||
lines.append(cmd["description"])
|
||||
lines.append("")
|
||||
|
||||
if cmd.get("example"):
|
||||
lines.append("**示例:**")
|
||||
lines.append(f"```\n{cmd['example']}\n```")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_tools_for_llm(registry: ToolRegistry) -> str:
|
||||
"""生成给 LLM 的工具列表"""
|
||||
tools = registry.list_enabled()
|
||||
|
||||
sections = ["## 可用工具\n"]
|
||||
|
||||
for tool in tools:
|
||||
manifest = load_manifest(tool.name)
|
||||
sections.append(generate_tool_description(manifest))
|
||||
sections.append("\n---\n")
|
||||
|
||||
return "\n".join(sections)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 权限控制
|
||||
|
||||
### 5.1 工具权限
|
||||
|
||||
```python
|
||||
# tools/permissions.py
|
||||
from enum import Enum
|
||||
from typing import Set
|
||||
|
||||
|
||||
class ToolPermission(str, Enum):
|
||||
"""工具权限"""
|
||||
EXECUTE = "tool:execute"
|
||||
CONFIGURE = "tool:configure"
|
||||
ENABLE = "tool:enable"
|
||||
DISABLE = "tool:disable"
|
||||
|
||||
|
||||
class ToolPermissionChecker:
|
||||
"""工具权限检查"""
|
||||
|
||||
def __init__(self):
|
||||
self._user_permissions: Dict[str, Set[ToolPermission]] = {}
|
||||
self._tool_roles: Dict[str, Set[str]] = {} # tool_name -> required_roles
|
||||
|
||||
def set_user_permissions(
|
||||
self,
|
||||
user_id: str,
|
||||
permissions: Set[ToolPermission],
|
||||
) -> None:
|
||||
"""设置用户权限"""
|
||||
self._user_permissions[user_id] = permissions
|
||||
|
||||
def set_tool_roles(
|
||||
self,
|
||||
tool_name: str,
|
||||
required_roles: Set[str],
|
||||
) -> None:
|
||||
"""设置工具所需角色"""
|
||||
self._tool_roles[tool_name] = required_roles
|
||||
|
||||
def can_execute(self, user_id: str, tool_name: str) -> bool:
|
||||
"""检查用户是否可以执行工具"""
|
||||
# 检查全局权限
|
||||
if ToolPermission.EXECUTE in self._user_permissions.get(user_id, set()):
|
||||
return True
|
||||
|
||||
# 检查工具特定角色
|
||||
required_roles = self._tool_roles.get(tool_name, set())
|
||||
if not required_roles:
|
||||
return True
|
||||
|
||||
# TODO: 检查用户是否有所需角色
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 集成到 Agent
|
||||
|
||||
### 6.1 LangChain 集成
|
||||
|
||||
```python
|
||||
# tools/langchain_adapter.py
|
||||
from typing import List, Optional
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LangChainToolAdapter:
|
||||
"""LangChain 工具适配器"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry):
|
||||
self.registry = registry
|
||||
|
||||
def to_langchain_tools(self) -> List[BaseTool]:
|
||||
"""转换为 LangChain 工具"""
|
||||
tools = []
|
||||
|
||||
for metadata in self.registry.list_enabled():
|
||||
executor = self.registry.get_executor(metadata.name)
|
||||
config = self.registry.get_config(metadata.name)
|
||||
|
||||
tool = self._create_tool(metadata, executor, config)
|
||||
tools.append(tool)
|
||||
|
||||
return tools
|
||||
|
||||
def _create_tool(
|
||||
self,
|
||||
metadata: ToolMetadata,
|
||||
executor: Callable,
|
||||
config: dict,
|
||||
) -> BaseTool:
|
||||
"""创建单个 LangChain 工具"""
|
||||
# 根据 manifest 创建工具
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 实现 ToolRegistry 类 | 🟢 高 |
|
||||
| 2 | 实现 ToolDiscovery | 🟢 高 |
|
||||
| 3 | 实现工具描述生成 | 🟡 中 |
|
||||
| 4 | 实现权限检查 | 🟡 中 |
|
||||
| 5 | 实现 LangChain 适配器 | 🟡 中 |
|
||||
| 6 | 集成到 Agent | 🟢 高 |
|
||||
| 7 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `tools/registry.py` | 新增 |
|
||||
| `tools/discovery.py` | 新增 |
|
||||
| `tools/description.py` | 新增 |
|
||||
| `tools/permissions.py` | 新增 |
|
||||
| `tools/langchain_adapter.py` | 新增 |
|
||||
| `tools/__init__.py` | 更新导出 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| ToolRegistry | 0.5 天 |
|
||||
| ToolDiscovery | 0.5 天 |
|
||||
| 描述生成 | 0.3 天 |
|
||||
| 权限检查 | 0.3 天 |
|
||||
| LangChain 适配 | 0.3 天 |
|
||||
| 集成 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **2.5 天** |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
- [ ] ToolRegistry 可正确注册/注销工具
|
||||
- [ ] ToolDiscovery 可发现所有 Manifest
|
||||
- [ ] 工具描述生成正确
|
||||
- [ ] 权限检查正常工作
|
||||
- [ ] LangChain 工具可正常转换
|
||||
- [ ] Agent 可使用注册的工具
|
||||
- [ ] 单元测试通过
|
||||
@@ -0,0 +1,586 @@
|
||||
# Phase T.3:核心工具实现
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:T.2(待完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现 Jarvis 的核心工具:
|
||||
|
||||
- 文件操作工具
|
||||
- 搜索工具
|
||||
- 网页抓取工具
|
||||
- 任务管理工具
|
||||
|
||||
---
|
||||
|
||||
## 2. 文件操作工具
|
||||
|
||||
### 2.1 实现
|
||||
|
||||
```python
|
||||
# tools/implementations/file_operator.py
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class FileOperator:
|
||||
"""文件操作工具"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.allowed_dirs = self._parse_allowed_dirs(
|
||||
config.get("allowed_directories", "")
|
||||
)
|
||||
self.max_file_size = config.get("max_file_size", 10 * 1024 * 1024)
|
||||
|
||||
def _parse_allowed_dirs(self, dirs_str: str) -> Optional[List[str]]:
|
||||
"""解析允许目录"""
|
||||
if not dirs_str:
|
||||
return None
|
||||
return [d.strip() for d in dirs_str.split(",") if d.strip()]
|
||||
|
||||
def _check_path(self, path: str) -> bool:
|
||||
"""检查路径是否允许"""
|
||||
if not self.allowed_dirs:
|
||||
return True
|
||||
resolved = Path(path).resolve()
|
||||
return any(
|
||||
str(resolved).startswith(allowed)
|
||||
for allowed in self.allowed_dirs
|
||||
)
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
filePath: str,
|
||||
encoding: str = "utf-8",
|
||||
) -> Dict[str, Any]:
|
||||
"""读取文件"""
|
||||
if not self._check_path(filePath):
|
||||
return {"status": "error", "error": "路径不在允许范围内"}
|
||||
|
||||
path = Path(filePath)
|
||||
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "文件不存在"}
|
||||
|
||||
if path.stat().st_size > self.max_file_size:
|
||||
return {"status": "error", "error": "文件过大"}
|
||||
|
||||
# 根据扩展名处理
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in [".pdf", ".docx", ".xlsx", ".xls", ".csv"]:
|
||||
return await self._read_binary_file(path)
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding=encoding)
|
||||
return {"status": "success", "result": content}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _read_binary_file(self, path: Path) -> Dict[str, Any]:
|
||||
"""读取二进制文件"""
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == ".pdf":
|
||||
return await self._read_pdf(path)
|
||||
elif suffix in [".docx", ".doc"]:
|
||||
return await self._read_docx(path)
|
||||
elif suffix in [".xlsx", ".xls"]:
|
||||
return await self._read_xlsx(path)
|
||||
elif suffix == ".csv":
|
||||
return await self._read_csv(path)
|
||||
|
||||
return {"status": "error", "error": "不支持的文件格式"}
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
filePath: str,
|
||||
content: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""写入文件"""
|
||||
if not self._check_path(filePath):
|
||||
return {"status": "error", "error": "路径不在允许范围内"}
|
||||
|
||||
path = Path(filePath)
|
||||
|
||||
# 如果文件存在,自动创建新文件名
|
||||
if path.exists():
|
||||
path = self._get_unique_path(path)
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"status": "success",
|
||||
"result": f"文件已保存: {path.name}",
|
||||
"path": str(path),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def _get_unique_path(self, path: Path) -> Path:
|
||||
"""获取唯一路径"""
|
||||
if not path.exists():
|
||||
return path
|
||||
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
counter = 1
|
||||
|
||||
while True:
|
||||
new_path = parent / f"{stem}({counter}){suffix}"
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
async def list_directory(
|
||||
self,
|
||||
directoryPath: str,
|
||||
showHidden: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""列出目录"""
|
||||
if not self._check_path(directoryPath):
|
||||
return {"status": "error", "error": "路径不在允许范围内"}
|
||||
|
||||
path = Path(directoryPath)
|
||||
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "目录不存在"}
|
||||
|
||||
if not path.is_dir():
|
||||
return {"status": "error", "error": "不是目录"}
|
||||
|
||||
items = []
|
||||
for item in path.iterdir():
|
||||
if not showHidden and item.name.startswith("."):
|
||||
continue
|
||||
items.append({
|
||||
"name": item.name,
|
||||
"type": "directory" if item.is_dir() else "file",
|
||||
"size": item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
|
||||
return {"status": "success", "result": items}
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
searchPath: str,
|
||||
pattern: str,
|
||||
**options,
|
||||
) -> Dict[str, Any]:
|
||||
"""搜索文件"""
|
||||
import fnmatch
|
||||
|
||||
if not self._check_path(searchPath):
|
||||
return {"status": "error", "error": "路径不在允许范围内"}
|
||||
|
||||
path = Path(searchPath)
|
||||
if not path.exists():
|
||||
return {"status": "error", "error": "路径不存在"}
|
||||
|
||||
case_sensitive = options.get("caseSensitive", False)
|
||||
file_type = options.get("fileType", "all")
|
||||
include_hidden = options.get("includeHidden", False)
|
||||
|
||||
results = []
|
||||
for item in path.rglob("*"):
|
||||
if not include_hidden and item.name.startswith("."):
|
||||
continue
|
||||
|
||||
if not fnmatch.fnmatch(item.name, pattern):
|
||||
continue
|
||||
|
||||
if file_type == "file" and item.is_dir():
|
||||
continue
|
||||
if file_type == "directory" and item.is_file():
|
||||
continue
|
||||
|
||||
results.append(str(item))
|
||||
|
||||
return {"status": "success", "result": results[:100]} # 限制结果数
|
||||
```
|
||||
|
||||
### 2.2 Manifest 绑定
|
||||
|
||||
```python
|
||||
# tools/implementations/__init__.py
|
||||
from tools.implementations.file_operator import FileOperator
|
||||
|
||||
|
||||
def create_file_operator_executor(config: dict):
|
||||
"""创建文件操作执行器"""
|
||||
operator = FileOperator(config)
|
||||
|
||||
async def execute(command: str, parameters: dict) -> dict:
|
||||
if command == "read_file":
|
||||
return await operator.read_file(**parameters)
|
||||
elif command == "write_file":
|
||||
return await operator.write_file(**parameters)
|
||||
elif command == "list_directory":
|
||||
return await operator.list_directory(**parameters)
|
||||
elif command == "search_files":
|
||||
return await operator.search_files(**parameters)
|
||||
else:
|
||||
return {"status": "error", "error": f"未知命令: {command}"}
|
||||
|
||||
return execute
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 搜索工具
|
||||
|
||||
### 3.1 实现
|
||||
|
||||
```python
|
||||
# tools/implementations/web_search.py
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
class WebSearch:
|
||||
"""联网搜索工具"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.api_key = config.get("api_key")
|
||||
self.max_results = config.get("max_results", 10)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行搜索"""
|
||||
try:
|
||||
# 实现搜索逻辑
|
||||
results = await self._do_search(
|
||||
query,
|
||||
max_results or self.max_results,
|
||||
)
|
||||
return {"status": "success", "result": results}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _do_search(self, query: str, limit: int) -> List[dict]:
|
||||
"""实际搜索"""
|
||||
# TODO: 接入搜索 API
|
||||
return []
|
||||
|
||||
async def deep_search(
|
||||
self,
|
||||
query: str,
|
||||
keywords: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""深度搜索"""
|
||||
try:
|
||||
# 并发执行多个搜索
|
||||
tasks = [
|
||||
self._do_search(kw, 5)
|
||||
for kw in [query] + keywords
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# 聚合结果
|
||||
aggregated = self._aggregate_results(results)
|
||||
|
||||
return {"status": "success", "result": aggregated}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def _aggregate_results(self, results: List[List[dict]]) -> dict:
|
||||
"""聚合搜索结果"""
|
||||
# TODO: 实现结果聚合
|
||||
return {"summary": "聚合结果", "sources": []}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 网页抓取工具
|
||||
|
||||
### 4.1 实现
|
||||
|
||||
```python
|
||||
# tools/implementations/web_fetch.py
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""抓取结果"""
|
||||
url: str
|
||||
title: Optional[str]
|
||||
content: str
|
||||
images: List[str]
|
||||
links: List[str]
|
||||
status: int
|
||||
|
||||
|
||||
class WebFetch:
|
||||
"""网页抓取工具"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.timeout = config.get("timeout", 30)
|
||||
self.user_agent = config.get(
|
||||
"user_agent",
|
||||
"Mozilla/5.0 (compatible; Jarvis/1.0)"
|
||||
)
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
url: str,
|
||||
include_images: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""抓取网页"""
|
||||
try:
|
||||
result = await self._do_fetch(url, include_images)
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"url": result.url,
|
||||
"title": result.title,
|
||||
"content": result.content,
|
||||
"images": result.images,
|
||||
"status": result.status,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _do_fetch(
|
||||
self,
|
||||
url: str,
|
||||
include_images: bool,
|
||||
) -> FetchResult:
|
||||
"""实际抓取"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# TODO: 解析 HTML 提取内容
|
||||
return FetchResult(
|
||||
url=url,
|
||||
title=None,
|
||||
content=response.text,
|
||||
images=[],
|
||||
links=[],
|
||||
status=response.status_code,
|
||||
)
|
||||
|
||||
async def screenshot(
|
||||
self,
|
||||
url: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""截取网页截图"""
|
||||
# TODO: 接入截图服务
|
||||
return {"status": "error", "error": "未实现"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务管理工具
|
||||
|
||||
### 5.1 实现
|
||||
|
||||
```python
|
||||
# tools/implementations/task_manager.py
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""任务"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
scheduled_at: Optional[datetime] = None
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""任务管理工具"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
|
||||
async def create_task(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
scheduled_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""创建任务"""
|
||||
import uuid
|
||||
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
task = Task(
|
||||
id=task_id,
|
||||
name=name,
|
||||
description=description,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
self._tasks[task_id] = task
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"id": task_id,
|
||||
"name": task.name,
|
||||
"status": task.status.value,
|
||||
}
|
||||
}
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
status: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""列出任务"""
|
||||
tasks = list(self._tasks.values())
|
||||
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status.value == status]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"status": t.status.value,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
for t in tasks
|
||||
]
|
||||
}
|
||||
|
||||
async def get_task(self, task_id: str) -> Dict[str, Any]:
|
||||
"""获取任务"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "任务不存在"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"description": task.description,
|
||||
"status": task.status.value,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
}
|
||||
}
|
||||
|
||||
async def complete_task(
|
||||
self,
|
||||
task_id: str,
|
||||
result: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""完成任务"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "任务不存在"}
|
||||
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.result = result
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
async def fail_task(
|
||||
self,
|
||||
task_id: str,
|
||||
error: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""标记任务失败"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return {"status": "error", "error": "任务不存在"}
|
||||
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = error
|
||||
|
||||
return {"status": "success"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 实现 FileOperator | 🟢 高 |
|
||||
| 2 | 实现 WebSearch | 🟡 中 |
|
||||
| 3 | 实现 WebFetch | 🟡 中 |
|
||||
| 4 | 实现 TaskManager | 🟡 中 |
|
||||
| 5 | 创建 Manifest 文件 | 🟢 高 |
|
||||
| 6 | 注册到工具中心 | 🟢 高 |
|
||||
| 7 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `tools/implementations/__init__.py` | 新增 |
|
||||
| `tools/implementations/file_operator.py` | 新增 |
|
||||
| `tools/implementations/web_search.py` | 新增 |
|
||||
| `tools/implementations/web_fetch.py` | 新增 |
|
||||
| `tools/implementations/task_manager.py` | 新增 |
|
||||
| `tools/manifests/file_operator.yaml` | 更新 |
|
||||
| `tools/manifests/web_search.yaml` | 新增 |
|
||||
| `tools/manifests/web_fetch.yaml` | 新增 |
|
||||
| `tools/manifests/task_manager.yaml` | 新增 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| FileOperator | 1.5 天 |
|
||||
| WebSearch | 1 天 |
|
||||
| WebFetch | 1 天 |
|
||||
| TaskManager | 0.5 天 |
|
||||
| Manifest + 注册 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **5 天** |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- [ ] FileOperator 可正确读写文件
|
||||
- [ ] FileOperator 支持多种格式解析
|
||||
- [ ] FileOperator 路径安全检查正常
|
||||
- [ ] WebSearch 可执行搜索
|
||||
- [ ] WebFetch 可抓取网页
|
||||
- [ ] TaskManager 可管理任务
|
||||
- [ ] 所有工具注册到工具中心
|
||||
- [ ] 单元测试通过
|
||||
642
development-doc/plan/tool-update/phase-t-4-advanced.md
Normal file
642
development-doc/plan/tool-update/phase-t-4-advanced.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# Phase T.4:高级特性
|
||||
|
||||
日期:2026-04-04
|
||||
状态:待开始
|
||||
依赖:T.3(待完成)
|
||||
|
||||
---
|
||||
|
||||
## 1. 本阶段目的
|
||||
|
||||
实现 Jarvis 工具系统的高级特性:
|
||||
|
||||
- 多运行时支持(Python/JS/原生)
|
||||
- Agent 间协作
|
||||
- 定时任务
|
||||
|
||||
---
|
||||
|
||||
## 2. 多运行时支持
|
||||
|
||||
### 2.1 运行时架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Runtime Manager │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Python │ │ JS │ │ Native │ │ WASM │ │
|
||||
│ │ Runtime │ │ Runtime │ │ Runtime │ │ Runtime │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────┴──────┬──────┴─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────┴────────┐ │
|
||||
│ │ Tool Executor │ │
|
||||
│ │ (统一接口) │ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 运行时基类
|
||||
|
||||
```python
|
||||
# tools/runtime/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class BaseRuntime(ABC):
|
||||
"""运行时基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行工具"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def validate(self, entry: str) -> bool:
|
||||
"""验证工具是否可用"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""获取运行时名称"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.3 Python 运行时
|
||||
|
||||
```python
|
||||
# tools/runtime/python_runtime.py
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class PythonRuntime(BaseRuntime):
|
||||
"""Python 运行时"""
|
||||
|
||||
def __init__(self):
|
||||
self._executors: Dict[str, Callable] = {}
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "python"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
path = Path(entry)
|
||||
return path.exists() and path.suffix == ".py"
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
# 动态加载并执行
|
||||
# 或者通过 subprocess 调用
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.4 JavaScript 运行时
|
||||
|
||||
```python
|
||||
# tools/runtime/js_runtime.py
|
||||
import asyncio
|
||||
import subprocess
|
||||
import json
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class JavaScriptRuntime(BaseRuntime):
|
||||
"""JavaScript 运行时"""
|
||||
|
||||
def __init__(self):
|
||||
self.node_path = "node" # 可配置
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "javascript"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
# 检查 node 是否可用
|
||||
try:
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
self.node_path, "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
# 通过 stdio 调用 Node.js 脚本
|
||||
input_data = json.dumps({
|
||||
"command": command,
|
||||
"parameters": parameters,
|
||||
})
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.node_path, entry,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=input_data.encode()),
|
||||
timeout=timeout / 1000,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": stderr.decode(),
|
||||
}
|
||||
|
||||
return json.loads(stdout.decode())
|
||||
```
|
||||
|
||||
### 2.5 原生运行时
|
||||
|
||||
```python
|
||||
# tools/runtime/native_runtime.py
|
||||
import asyncio
|
||||
import subprocess
|
||||
from tools.runtime.base import BaseRuntime
|
||||
|
||||
|
||||
class NativeRuntime(BaseRuntime):
|
||||
"""原生二进制运行时"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "native"
|
||||
|
||||
async def validate(self, entry: str) -> bool:
|
||||
from pathlib import Path
|
||||
path = Path(entry)
|
||||
return path.exists() and path.stat().st_mode & 0o111
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
# 调用原生可执行文件
|
||||
args = [entry, command] + self._format_args(parameters)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout / 1000,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": stderr.decode(),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": stdout.decode(),
|
||||
}
|
||||
|
||||
def _format_args(self, parameters: Dict[str, Any]) -> list:
|
||||
"""格式化参数"""
|
||||
args = []
|
||||
for key, value in parameters.items():
|
||||
args.extend([f"--{key}", str(value)])
|
||||
return args
|
||||
```
|
||||
|
||||
### 2.6 运行时管理器
|
||||
|
||||
```python
|
||||
# tools/runtime/manager.py
|
||||
from tools.runtime.base import BaseRuntime
|
||||
from tools.runtime.python_runtime import PythonRuntime
|
||||
from tools.runtime.js_runtime import JavaScriptRuntime
|
||||
from tools.runtime.native_runtime import NativeRuntime
|
||||
|
||||
|
||||
class RuntimeManager:
|
||||
"""运行时管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._runtimes: Dict[str, BaseRuntime] = {
|
||||
"python": PythonRuntime(),
|
||||
"javascript": JavaScriptRuntime(),
|
||||
"native": NativeRuntime(),
|
||||
}
|
||||
|
||||
def get_runtime(self, name: str) -> BaseRuntime:
|
||||
return self._runtimes.get(name)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
runtime_name: str,
|
||||
entry: str,
|
||||
command: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
runtime = self.get_runtime(runtime_name)
|
||||
if not runtime:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"未知运行时: {runtime_name}",
|
||||
}
|
||||
|
||||
return await runtime.execute(entry, command, parameters, timeout)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent 间协作
|
||||
|
||||
### 3.1 协作协议
|
||||
|
||||
```python
|
||||
# agents/tools/collaboration.py
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
REQUEST = "request" # 请求协作
|
||||
RESPONSE = "response" # 响应结果
|
||||
PROGRESS = "progress" # 进度更新
|
||||
CANCEL = "cancel" # 取消请求
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollaborationMessage:
|
||||
"""协作消息"""
|
||||
id: str
|
||||
type: MessageType
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
content: Any
|
||||
metadata: Dict[str, Any]
|
||||
timestamp: datetime = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.utcnow()
|
||||
|
||||
|
||||
class CollaborationProtocol:
|
||||
"""Agent 协作协议"""
|
||||
|
||||
def __init__(self):
|
||||
self._pending_requests: Dict[str, CollaborationMessage] = {}
|
||||
self._handlers: Dict[str, callable] = {}
|
||||
|
||||
def register_handler(self, tool_name: str, handler: callable) -> None:
|
||||
"""注册工具处理器"""
|
||||
self._handlers[tool_name] = handler
|
||||
|
||||
async def request_collaboration(
|
||||
self,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
tool_name: str,
|
||||
parameters: Dict[str, Any],
|
||||
timeout: int = 30000,
|
||||
) -> Dict[str, Any]:
|
||||
"""请求协作"""
|
||||
import uuid
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
message = CollaborationMessage(
|
||||
id=request_id,
|
||||
type=MessageType.REQUEST,
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
content={
|
||||
"tool": tool_name,
|
||||
"parameters": parameters,
|
||||
},
|
||||
metadata={"timeout": timeout},
|
||||
)
|
||||
|
||||
self._pending_requests[request_id] = message
|
||||
|
||||
# 发送请求
|
||||
await self._send_message(message)
|
||||
|
||||
# 等待响应
|
||||
try:
|
||||
response = await self._wait_for_response(
|
||||
request_id,
|
||||
timeout,
|
||||
)
|
||||
return response
|
||||
except TimeoutError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "协作请求超时",
|
||||
}
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
message: CollaborationMessage,
|
||||
) -> CollaborationMessage:
|
||||
"""处理协作请求"""
|
||||
tool_name = message.content["tool"]
|
||||
parameters = message.content["parameters"]
|
||||
|
||||
handler = self._handlers.get(tool_name)
|
||||
if not handler:
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={
|
||||
"status": "error",
|
||||
"error": f"未知工具: {tool_name}",
|
||||
},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await handler(**parameters)
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={"status": "success", "result": result},
|
||||
metadata={},
|
||||
)
|
||||
except Exception as e:
|
||||
return CollaborationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
type=MessageType.RESPONSE,
|
||||
from_agent=message.to_agent,
|
||||
to_agent=message.from_agent,
|
||||
content={"status": "error", "error": str(e)},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def _send_message(self, message: CollaborationMessage) -> None:
|
||||
"""发送消息"""
|
||||
# TODO: 实现消息发送(WebSocket/消息队列)
|
||||
pass
|
||||
|
||||
async def _wait_for_response(
|
||||
self,
|
||||
request_id: str,
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""等待响应"""
|
||||
# TODO: 实现等待逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 定时任务
|
||||
|
||||
### 4.1 定时任务服务
|
||||
|
||||
```python
|
||||
# tools/scheduler.py
|
||||
import asyncio
|
||||
from typing import Dict, Any, Callable, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
ONCE = "once" # 单次
|
||||
INTERVAL = "interval" # 间隔
|
||||
CRON = "cron" # Cron 表达式
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
"""定时任务"""
|
||||
id: str
|
||||
name: str
|
||||
schedule_type: ScheduleType
|
||||
schedule_value: str # 时间/间隔/cron
|
||||
tool_name: str
|
||||
parameters: Dict[str, Any]
|
||||
enabled: bool = True
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
run_count: int = 0
|
||||
callback: Optional[Callable] = field(default=None)
|
||||
|
||||
|
||||
class ToolScheduler:
|
||||
"""工具定时调度器"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, ScheduledTask] = {}
|
||||
self._running = False
|
||||
self._loop_task = None
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
name: str,
|
||||
schedule_type: ScheduleType,
|
||||
schedule_value: str,
|
||||
tool_name: str,
|
||||
parameters: Dict[str, Any],
|
||||
callback: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""创建定时任务"""
|
||||
import uuid
|
||||
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
|
||||
task = ScheduledTask(
|
||||
id=task_id,
|
||||
name=name,
|
||||
schedule_type=schedule_type,
|
||||
schedule_value=schedule_value,
|
||||
tool_name=tool_name,
|
||||
parameters=parameters,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
task.next_run = self._calculate_next_run(task)
|
||||
self._tasks[task_id] = task
|
||||
|
||||
# 启动调度器
|
||||
if not self._running:
|
||||
await self.start()
|
||||
|
||||
return task_id
|
||||
|
||||
def _calculate_next_run(self, task: ScheduledTask) -> datetime:
|
||||
"""计算下次运行时间"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
if task.schedule_type == ScheduleType.ONCE:
|
||||
return datetime.fromisoformat(task.schedule_value)
|
||||
|
||||
elif task.schedule_type == ScheduleType.INTERVAL:
|
||||
seconds = int(task.schedule_value)
|
||||
return now + timedelta(seconds=seconds)
|
||||
|
||||
elif task.schedule_type == ScheduleType.CRON:
|
||||
# 解析 cron 表达式
|
||||
# TODO: 实现 cron 解析
|
||||
return now + timedelta(hours=1)
|
||||
|
||||
return now
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动调度器"""
|
||||
self._running = True
|
||||
self._loop_task = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止调度器"""
|
||||
self._running = False
|
||||
if self._loop_task:
|
||||
self._loop_task.cancel()
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""调度循环"""
|
||||
while self._running:
|
||||
now = datetime.utcnow()
|
||||
|
||||
for task in self._tasks.values():
|
||||
if not task.enabled:
|
||||
continue
|
||||
|
||||
if task.next_run and task.next_run <= now:
|
||||
await self._execute_task(task)
|
||||
|
||||
await asyncio.sleep(1) # 每秒检查一次
|
||||
|
||||
async def _execute_task(self, task: ScheduledTask) -> None:
|
||||
"""执行任务"""
|
||||
# 调用工具
|
||||
executor = get_executor(task.tool_name)
|
||||
result = await executor(
|
||||
command=task.parameters.get("command"),
|
||||
parameters=task.parameters,
|
||||
)
|
||||
|
||||
# 更新状态
|
||||
task.last_run = datetime.utcnow()
|
||||
task.run_count += 1
|
||||
|
||||
# 计算下次运行
|
||||
if task.schedule_type != ScheduleType.ONCE:
|
||||
task.next_run = self._calculate_next_run(task)
|
||||
else:
|
||||
task.enabled = False
|
||||
|
||||
# 调用回调
|
||||
if task.callback:
|
||||
await task.callback(task, result)
|
||||
|
||||
async def cancel(self, task_id: str) -> bool:
|
||||
"""取消任务"""
|
||||
if task_id in self._tasks:
|
||||
del self._tasks[task_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def list_tasks(self) -> list:
|
||||
"""列出所有任务"""
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"type": t.schedule_type.value,
|
||||
"enabled": t.enabled,
|
||||
"next_run": t.next_run.isoformat() if t.next_run else None,
|
||||
"run_count": t.run_count,
|
||||
}
|
||||
for t in self._tasks.values()
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现步骤
|
||||
|
||||
| 步骤 | 任务 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 1 | 实现运行时基类 | 🟢 高 |
|
||||
| 2 | 实现 Python 运行时 | 🟢 高 |
|
||||
| 3 | 实现 JS 运行时 | 🟡 中 |
|
||||
| 4 | 实现原生运行时 | 🟡 中 |
|
||||
| 5 | 实现运行时管理器 | 🟢 高 |
|
||||
| 6 | 实现协作协议 | 🟡 中 |
|
||||
| 7 | 实现定时调度器 | 🟡 中 |
|
||||
| 8 | 单元测试 | 🟡 中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心文件变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `tools/runtime/__init__.py` | 新增 |
|
||||
| `tools/runtime/base.py` | 新增 |
|
||||
| `tools/runtime/python_runtime.py` | 新增 |
|
||||
| `tools/runtime/js_runtime.py` | 新增 |
|
||||
| `tools/runtime/native_runtime.py` | 新增 |
|
||||
| `tools/runtime/manager.py` | 新增 |
|
||||
| `agents/tools/collaboration.py` | 新增 |
|
||||
| `tools/scheduler.py` | 新增 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 工作量估算
|
||||
|
||||
| 任务 | 工作量 |
|
||||
|------|--------|
|
||||
| 运行时基类 | 0.5 天 |
|
||||
| Python 运行时 | 0.5 天 |
|
||||
| JS 运行时 | 0.5 天 |
|
||||
| 原生运行时 | 0.5 天 |
|
||||
| 运行时管理器 | 0.5 天 |
|
||||
| 协作协议 | 1 天 |
|
||||
| 定时调度器 | 0.5 天 |
|
||||
| 单元测试 | 0.5 天 |
|
||||
| **总计** | **4 天** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- [ ] Python 运行时可正常执行工具
|
||||
- [ ] JS 运行时可通过 stdio 调用
|
||||
- [ ] 原生运行时可执行二进制
|
||||
- [ ] 运行时管理器正确路由
|
||||
- [ ] 协作协议可正常请求/响应
|
||||
- [ ] 定时调度器可按计划执行任务
|
||||
- [ ] 单元测试通过
|
||||
@@ -1,347 +0,0 @@
|
||||
# Jarvis 个人 AI 助理 — Phase 1 实现计划
|
||||
|
||||
> 生成日期:2026-03-20
|
||||
> 目标:完成 Jarvis 核心功能的 MVP 版本
|
||||
|
||||
---
|
||||
|
||||
## 技术栈确认
|
||||
|
||||
| 组件 | 技术选型 |
|
||||
|------|---------|
|
||||
| **后端框架** | FastAPI (Python 3.12+) |
|
||||
| **Agent 框架** | LangGraph(多 Agent 编排、工具调用、状态机) |
|
||||
| **LLM 适配器** | LangChain Claude / OpenAI / Ollama(可切换) |
|
||||
| **知识库框架** | LlamaIndex(Node 关系索引、语义检索) |
|
||||
| **向量数据库** | ChromaDB |
|
||||
| **关系数据库** | SQLite + SQLAlchemy |
|
||||
| **前端框架** | Vue 3 + TypeScript + Vite |
|
||||
| **移动端** | Kotlin (Android) |
|
||||
| **定时任务** | APScheduler |
|
||||
| **部署** | Docker(NAS 本地运行) |
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
MyAgents/
|
||||
├── backend/ # 后端项目
|
||||
│ ├── app/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── main.py # FastAPI 入口
|
||||
│ │ ├── config.py # 配置管理
|
||||
│ │ ├── database.py # SQLAlchemy 数据库连接
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── user.py
|
||||
│ │ │ ├── document.py
|
||||
│ │ │ ├── task.py
|
||||
│ │ │ ├── forum.py
|
||||
│ │ │ ├── agent.py
|
||||
│ │ │ ├── conversation.py
|
||||
│ │ │ └── knowledge_graph.py
|
||||
│ │ ├── schemas/ # Pydantic 请求/响应模型
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ ├── conversation.py
|
||||
│ │ │ ├── document.py
|
||||
│ │ │ ├── task.py
|
||||
│ │ │ ├── forum.py
|
||||
│ │ │ └── graph.py
|
||||
│ │ ├── routers/ # API 路由
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ ├── conversation.py
|
||||
│ │ │ ├── document.py
|
||||
│ │ │ ├── task.py
|
||||
│ │ │ ├── forum.py
|
||||
│ │ │ └── graph.py
|
||||
│ │ ├── agents/ # LangGraph Agent 系统
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── graph.py # 主 Agent 图定义
|
||||
│ │ │ ├── nodes/ # Agent 节点
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── master.py # 主调度 Agent
|
||||
│ │ │ │ ├── planner.py # 规划 Agent
|
||||
│ │ │ │ ├── executor.py # 执行 Agent
|
||||
│ │ │ │ ├── librarian.py # 知识管理员 Agent
|
||||
│ │ │ │ └── analyst.py # 分析师 Agent
|
||||
│ │ │ ├── tools/ # Agent 工具集
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── search.py # 知识库检索工具
|
||||
│ │ │ │ ├── task.py # 任务操作工具
|
||||
│ │ │ │ ├── forum.py # 论坛操作工具
|
||||
│ │ │ │ └── graph.py # 图谱操作工具
|
||||
│ │ │ └── prompts/ # Agent 提示词
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── master_prompt.py
|
||||
│ │ │ └── sub_agent_prompts.py
|
||||
│ │ ├── services/ # 业务逻辑服务
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── llm_service.py # LLM 调用服务
|
||||
│ │ │ ├── knowledge_service.py # 知识库服务
|
||||
│ │ │ ├── graph_service.py # 知识图谱服务
|
||||
│ │ │ ├── scheduler_service.py # 定时任务服务
|
||||
│ │ │ └── agent_service.py # Agent 调用服务
|
||||
│ │ ├── knowledge/ # 知识库核心
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── indexer.py # LlamaIndex 索引器
|
||||
│ │ │ ├── chunker.py # 文档分块策略
|
||||
│ │ │ └── retriever.py # 检索器
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── security.py
|
||||
│ ├── tests/ # 测试
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── test_agents.py
|
||||
│ │ ├── test_knowledge.py
|
||||
│ │ └── test_api.py
|
||||
│ ├── pyproject.toml
|
||||
│ ├── uv.lock
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── frontend/ # 前端项目
|
||||
│ ├── src/
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── api/ # API 调用
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── conversation.ts
|
||||
│ │ │ ├── document.ts
|
||||
│ │ │ ├── task.ts
|
||||
│ │ │ ├── forum.ts
|
||||
│ │ │ └── graph.ts
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── views/ # 页面视图
|
||||
│ │ │ ├── ChatView.vue # 主对话页
|
||||
│ │ │ ├── KnowledgeView.vue # 知识库页
|
||||
│ │ │ ├── GraphView.vue # 知识图谱页
|
||||
│ │ │ ├── ForumView.vue # 论坛页
|
||||
│ │ │ ├── KanbanView.vue # 看板页
|
||||
│ │ │ └── LoginView.vue # 登录页
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── router/ # Vue Router
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── styles/ # 全局样式
|
||||
│ ├── public/
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│
|
||||
├── android/ # Android 项目(后续)
|
||||
│
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发阶段
|
||||
|
||||
### Phase 1 — 核心骨架(第 1-2 周)
|
||||
|
||||
**目标**:跑通最基础的服务,能对话、能上传文档、能检索
|
||||
|
||||
1. **搭建后端项目** — FastAPI + 项目结构 + 依赖安装
|
||||
2. **搭建前端项目** — Vue 3 + Vite + TypeScript + 基础路由
|
||||
3. **实现 LLM 适配器** — LangChain Claude/OpenAI/Ollama 统一接口
|
||||
4. **实现简单对话** — 单 Agent + WebSocket 流式输出
|
||||
5. **实现知识库上传** — LlamaIndex + ChromaDB + 文档分块
|
||||
6. **实现基础检索** — 向量检索 + 返回结果
|
||||
|
||||
### Phase 2 — 多 Agent 系统(第 3-4 周)
|
||||
|
||||
**目标**:多 Agent 协作跑起来
|
||||
|
||||
1. **实现主 Agent** — LangGraph 状态机 + 工具注册
|
||||
2. **实现子 Agent** — 规划、执行、知识管理、分析师 4 个角色
|
||||
3. **实现工具集** — 知识检索、任务操作、论坛操作工具
|
||||
4. **Agent 通信** — 协作式 + 主 Agent 协调模式
|
||||
|
||||
### Phase 3 — 知识图谱(第 5-6 周)
|
||||
|
||||
**目标**:文档知识能沉淀到图谱中
|
||||
|
||||
1. **实体识别** — LLM 从文档 Node 中提取实体
|
||||
2. **关系抽取** — LLM 抽取实体间关系
|
||||
3. **图谱存储** — nodes + edges 存入 SQLite
|
||||
4. **图谱可视化** — 前端 D3.js / ECharts 渲染
|
||||
|
||||
### Phase 4 — 论坛 + 看板(第 7-8 周)
|
||||
|
||||
**目标**:论坛发帖、AI 扫描执行、看板任务管理
|
||||
|
||||
1. **论坛 CRUD** — 帖子发布、回复、列表
|
||||
2. **AI 扫描引擎** — 定时扫描论坛、识别可执行指令
|
||||
3. **看板 CRUD** — 任务卡片、优先级、状态
|
||||
4. **AI 每日规划** — 凌晨分析完成情况、生成次日建议
|
||||
|
||||
### Phase 5 — 前端完整 UI(第 9-10 周)
|
||||
|
||||
**目标**:所有功能页面完成,科幻风格 UI
|
||||
|
||||
1. **主对话界面** — 流式输出、Agent 角色切换
|
||||
2. **知识库界面** — 文件上传、检索、结果展示
|
||||
3. **图谱可视化** — 可交互的节点关系图
|
||||
4. **论坛界面** — 发帖、列表、AI 执行标记
|
||||
5. **看板界面** — 拖拽卡片、状态流转、AI 建议
|
||||
|
||||
### Phase 6 — Android App(第 11-12 周)
|
||||
|
||||
**目标**:移动端能对话、能看看板
|
||||
|
||||
1. **Android 项目搭建** — Kotlin + Jetpack Compose
|
||||
2. **对话界面** — WebSocket 连接后端、流式对话
|
||||
3. **看板视图** — 任务列表、状态切换
|
||||
4. **基础设置** — 服务器地址配置
|
||||
|
||||
### Phase 7 — 部署 + 优化(第 13-14 周)
|
||||
|
||||
**目标**:部署到 NAS,稳定运行
|
||||
|
||||
1. **Docker 打包** — 后端 + 前端镜像
|
||||
2. **NAS 部署** — Docker Compose 一键启动
|
||||
3. **性能优化** — 缓存、异步、数据库索引
|
||||
4. **安全加固** — JWT、API 限流、数据加密
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 详细任务
|
||||
|
||||
### 1.1 后端项目初始化
|
||||
|
||||
```
|
||||
backend/
|
||||
├── pyproject.toml
|
||||
│ ├── fastapi>=0.115.0
|
||||
│ ├── uvicorn[standard]>=0.30.0
|
||||
│ ├── langgraph>=0.2.0
|
||||
│ ├── langchain-anthropic>=0.3.0
|
||||
│ ├── langchain-openai>=0.2.0
|
||||
│ ├── llama-index>=0.12.0
|
||||
│ ├── llama-index-vector-stores-chroma>=0.3.0
|
||||
│ ├── chromadb>=0.5.0
|
||||
│ ├── sqlalchemy>=2.0.0
|
||||
│ ├── aiosqlite>=0.20.0
|
||||
│ ├── pydantic>=2.0.0
|
||||
│ ├── pydantic-settings>=2.0.0
|
||||
│ ├── python-jose[cryptography]>=3.3.0
|
||||
│ ├── passlib[bcrypt]>=1.7.4
|
||||
│ ├── APScheduler>=3.10.0
|
||||
│ ├── python-multipart>=0.0.12
|
||||
│ ├── websockets>=12.0
|
||||
│ └── aiofiles>=24.0.0
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py
|
||||
│ ├── config.py
|
||||
│ └── database.py
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
### 1.2 前端项目初始化
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── Vite + Vue 3 + TypeScript
|
||||
├── Pinia (状态管理)
|
||||
├── Vue Router 4
|
||||
├── Axios (HTTP 客户端)
|
||||
├── VueUse (工具函数)
|
||||
├── TailwindCSS (样式)
|
||||
└── Lucide Vue (图标)
|
||||
```
|
||||
|
||||
### 1.3 LLM 适配器接口
|
||||
|
||||
```python
|
||||
# backend/app/services/llm_service.py
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_ollama import ChatOllama
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class LLMAdapter(ABC):
|
||||
@abstractmethod
|
||||
async def invoke(self, messages: list[dict]) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def stream(self, messages: list[dict]):
|
||||
...
|
||||
|
||||
class ClaudeAdapter(LLMAdapter):
|
||||
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
|
||||
self.llm = ChatAnthropic(api_key=api_key, model=model)
|
||||
|
||||
class OpenAIAdapter(LLMAdapter):
|
||||
def __init__(self, api_key: str, model: str = "gpt-4o"):
|
||||
self.llm = ChatOpenAI(api_key=api_key, model=model)
|
||||
|
||||
class OllamaAdapter(LLMAdapter):
|
||||
def __init__(self, base_url: str = "http://localhost:11434", model: str = "llama3"):
|
||||
self.llm = ChatOllama(base_url=base_url, model=model)
|
||||
|
||||
# 工厂函数,根据配置返回对应适配器
|
||||
def get_llm_adapter(provider: str, **kwargs) -> LLMAdapter:
|
||||
adapters = {
|
||||
"claude": ClaudeAdapter,
|
||||
"openai": OpenAIAdapter,
|
||||
"ollama": OllamaAdapter,
|
||||
}
|
||||
return adapters[provider](**kwargs)
|
||||
```
|
||||
|
||||
### 1.4 简单对话 API
|
||||
|
||||
```python
|
||||
# POST /api/chat
|
||||
# Body: { "message": "你好 Jarvis", "conversation_id": "uuid" }
|
||||
# Response: WebSocket 连接,流式返回
|
||||
|
||||
@app.websocket("/ws/chat/{conversation_id}")
|
||||
async def websocket_chat(websocket, conversation_id: str):
|
||||
async for message in websocket:
|
||||
# 1. 存入历史
|
||||
# 2. 调用 LangGraph Agent
|
||||
# 3. 流式返回结果
|
||||
yield "data: ..."
|
||||
|
||||
# GET /api/conversations — 对话历史列表
|
||||
# POST /api/conversations — 创建新对话
|
||||
# DELETE /api/conversations/{id} — 删除对话
|
||||
```
|
||||
|
||||
### 1.5 知识库上传 API
|
||||
|
||||
```python
|
||||
# POST /api/documents/upload
|
||||
# Body: multipart/form-data, file + metadata
|
||||
# 返回: document_id, chunk_count
|
||||
|
||||
# GET /api/documents — 文档列表
|
||||
# GET /api/documents/{id} — 文档详情 + chunks
|
||||
# DELETE /api/documents/{id} — 删除文档
|
||||
|
||||
# POST /api/documents/search
|
||||
# Body: { "query": "查找...", "top_k": 5 }
|
||||
# 返回: 检索结果列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第一步操作
|
||||
|
||||
现在开始执行 Phase 1.1 — 搭建后端项目结构。
|
||||
|
||||
需要创建:
|
||||
1. `backend/pyproject.toml`
|
||||
2. `backend/app/__init__.py`
|
||||
3. `backend/app/main.py`
|
||||
4. `backend/app/config.py`
|
||||
5. `backend/app/database.py`
|
||||
6. `backend/.env.example`
|
||||
|
||||
是否现在开始?
|
||||
@@ -1,711 +0,0 @@
|
||||
# Chat Enhancement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为沟通系统添加文件上传(AI理解内容)和表情包选择器功能
|
||||
|
||||
**Architecture:** 前端在 ChatView 输入区添加附件/Emoji按钮,后端扩展 ChatRequest 支持 file_ids,AgentService 读取文件内容作为上下文
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript + FastAPI + SQLAlchemy + ChromaDB
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
|
||||
├── components/
|
||||
│ └── chat/
|
||||
│ ├── EmojiPicker.vue # 新建 - Emoji选择器
|
||||
│ └── FileMessage.vue # 新建 - 文件消息气泡
|
||||
└── api/
|
||||
├── conversation.ts # 修改 - chat支持file_ids
|
||||
└── document.ts # 新增 - getDocumentContent
|
||||
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ ├── conversation.py # 修改 - ChatRequest支持file_ids
|
||||
│ └── document.py # 修改 - 新增content接口
|
||||
├── services/
|
||||
│ └── agent_service.py # 修改 - chat支持文件上下文
|
||||
└── models/
|
||||
└── conversation.py # 修改 - Message新增attachments字段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 EmojiPicker 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/chat/EmojiPicker.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 EmojiPicker.vue**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = [
|
||||
{ key: 'smile', name: '😀', label: '笑脸' },
|
||||
{ key: 'gesture', name: '👍', label: '手势' },
|
||||
{ key: 'object', name: '📦', label: '物品' },
|
||||
{ key: 'symbol', name: '💬', label: '符号' },
|
||||
]
|
||||
|
||||
const emojiData: Record<string, string[]> = {
|
||||
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
|
||||
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
|
||||
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
|
||||
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
|
||||
}
|
||||
|
||||
const activeCategory = ref('smile')
|
||||
|
||||
function selectEmoji(emoji: string) {
|
||||
emit('select', emoji)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="emoji-picker">
|
||||
<div class="emoji-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
:title="cat.label"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in emojiData[activeCategory]"
|
||||
:key="emoji"
|
||||
class="emoji-btn"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.emoji-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-tabs button:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 FileMessage 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/chat/FileMessage.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 FileMessage.vue**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileText, Image, File } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
}>()
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.fileType.startsWith('image/')) return Image
|
||||
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const fileSizeDisplay = computed(() => {
|
||||
const size = props.fileSize
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = props.filename.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div class="file-icon">
|
||||
<component :is="icon" :size="24" />
|
||||
<span v-if="ext" class="file-ext">{{ ext }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ filename }}</span>
|
||||
<span class="file-size">{{ fileSizeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
position: relative;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--bg-void);
|
||||
background: var(--accent-cyan);
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修改前端 API - conversation.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/conversation.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 conversation.ts**
|
||||
|
||||
```typescript
|
||||
// 在 chat 方法中添加 file_ids 参数
|
||||
chat(message: string, conversationId?: string, fileIds: string[] = []) {
|
||||
return api.post('/api/conversations/chat', {
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
file_ids: fileIds,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改前端 API - document.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 getContent 方法**
|
||||
|
||||
```typescript
|
||||
// 新增方法
|
||||
getContent(id: string) {
|
||||
return api.get<string>(`/api/documents/${id}/content`)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 ChatView.vue - 添加按钮和状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ChatView.vue`
|
||||
|
||||
- [ ] **Step 1: 在 script setup 中添加以下内容**
|
||||
|
||||
在 import 后添加:
|
||||
|
||||
```typescript
|
||||
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
|
||||
import FileMessage from '@/components/chat/FileMessage.vue'
|
||||
import { Paperclip, Smile, Download } from 'lucide-vue-next'
|
||||
|
||||
// 新增状态
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const showEmojiPicker = ref(false)
|
||||
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
|
||||
const uploadingFiles = ref<{ name: string; progress: number }[]>([])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加文件上传方法**
|
||||
|
||||
```typescript
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
for (const file of input.files) {
|
||||
// 校验大小
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(`文件 ${file.name} 超过10MB限制`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 显示上传中状态
|
||||
uploadingFiles.value.push({ name: file.name, progress: 0 })
|
||||
|
||||
try {
|
||||
const response = await documentApi.upload(file)
|
||||
selectedFiles.value.push({
|
||||
id: response.data.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('上传失败:', e)
|
||||
alert(`文件 ${file.name} 上传失败`)
|
||||
} finally {
|
||||
uploadingFiles.value = uploadingFiles.value.filter(f => f.name !== file.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function insertEmoji(emoji: string) {
|
||||
inputMessage.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 sendMessage 中处理文件上传**
|
||||
|
||||
修改 sendMessage 函数,在发送消息时附带 file_ids:
|
||||
|
||||
```typescript
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.value.trim() || isSending.value) return
|
||||
|
||||
// 如果有文件,先上传
|
||||
if (selectedFiles.value.length > 0) {
|
||||
// file_ids 已经在 selectedFiles 中
|
||||
}
|
||||
|
||||
isSending.value = true
|
||||
isTyping.value = true
|
||||
const text = inputMessage.value.trim()
|
||||
const fileIds = selectedFiles.value.map(f => f.id)
|
||||
|
||||
// 添加用户消息(带文件)
|
||||
store.addMessage({
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
attachments: selectedFiles.value,
|
||||
})
|
||||
|
||||
inputMessage.value = ''
|
||||
selectedFiles.value = [] // 清空已选文件
|
||||
|
||||
// ... 后续发送逻辑,传入 fileIds
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 template 中添加附件和 Emoji 按钮**
|
||||
|
||||
在输入框的按钮区域添加:
|
||||
|
||||
```vue
|
||||
<!-- 文件选择 input(隐藏) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 附件按钮 -->
|
||||
<button class="attach-btn" @click="openFilePicker" title="上传文件">
|
||||
<Paperclip :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- Emoji 按钮 -->
|
||||
<div class="emoji-wrapper">
|
||||
<button
|
||||
class="emoji-btn"
|
||||
:class="{ active: showEmojiPicker }"
|
||||
@click="showEmojiPicker = !showEmojiPicker"
|
||||
title="表情包"
|
||||
>
|
||||
<Smile :size="15" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
:visible="showEmojiPicker"
|
||||
@select="insertEmoji"
|
||||
@close="showEmojiPicker = false"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 添加样式**
|
||||
|
||||
在 style 部分添加:
|
||||
|
||||
```css
|
||||
/* 文件消息样式 */
|
||||
.file-msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.file-msg-row .msg-avatar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 附件按钮 */
|
||||
.attach-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Emoji 按钮 */
|
||||
.emoji-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover,
|
||||
.emoji-btn.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改后端 - ChatRequest 支持 file_ids
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 ChatRequest**
|
||||
|
||||
```python
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
file_ids: list[str] = [] # 新增
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 修改后端 - Message 新增 attachments 字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/conversation.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Message 模型**
|
||||
|
||||
```python
|
||||
class Message(BaseModel):
|
||||
__tablename__ = "messages"
|
||||
|
||||
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False) # user, assistant, system
|
||||
content = Column(Text, nullable=False)
|
||||
model = Column(String(100), nullable=True)
|
||||
tokens_used = Column(Integer, nullable=True)
|
||||
attachments = Column(JSON, nullable=True) # 新增: [{file_id, filename, file_type, file_size}]
|
||||
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 修改后端 - 新增 document content 接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
|
||||
- [ ] **Step 1: 添加 content 接口**
|
||||
|
||||
```python
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取文档的文本内容(用于AI理解)"""
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
doc_svc = DocumentService(db)
|
||||
content = await doc_svc.get_document_content(current_user.id, document_id)
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="文档不存在或无内容")
|
||||
|
||||
return {"content": content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 修改后端 - DocumentService 新增 get_document_content
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 添加 get_document_content 方法**
|
||||
|
||||
```python
|
||||
async def get_document_content(self, user_id: str, document_id: str) -> str | None:
|
||||
"""获取文档的文本内容"""
|
||||
import os
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user_id,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
file_path = doc.file_path
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# 根据文件类型读取内容
|
||||
ext = doc.filename.split('.')[-1].lower()
|
||||
|
||||
try:
|
||||
if ext == 'txt':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'md':
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
elif ext == 'pdf':
|
||||
# 简单文本提取(生产环境应使用专业库)
|
||||
# 这里可以先用 pdfplumber 或 PyPDF2
|
||||
return f"[PDF文档] {doc.filename}"
|
||||
else:
|
||||
return f"[文档] {doc.filename}"
|
||||
except Exception:
|
||||
return f"[文档] {doc.filename}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 修改后端 - AgentService 支持文件上下文
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/agent_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 chat_simple 方法支持 file_ids**
|
||||
|
||||
在 chat_simple 方法中:
|
||||
|
||||
```python
|
||||
async def chat_simple(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
file_ids: list[str] = None,
|
||||
) -> tuple[str, str, str]:
|
||||
# ... 现有逻辑 ...
|
||||
|
||||
# 如果有文件,读取内容作为上下文
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
doc_svc = DocumentService(self.db)
|
||||
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
if content:
|
||||
file_context += f"\n\n[用户上传文件内容]\n{content}\n[/文件内容]"
|
||||
|
||||
# 将文件上下文添加到消息
|
||||
full_message = f"{message}\n{file_context}" if file_context else message
|
||||
|
||||
# 调用 LLM
|
||||
response = await self.llm.chat(full_message, ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 验证和测试
|
||||
|
||||
- [ ] **Step 1: 前端 TypeScript 检查**
|
||||
|
||||
```bash
|
||||
cd frontend && npx vue-tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 后端语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && python -m py_compile app/routers/conversation.py app/services/agent_service.py app/services/document_service.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启动服务测试**
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && python -m uvicorn app.main:app --reload
|
||||
|
||||
# 前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行选项
|
||||
|
||||
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
|
||||
|
||||
**2. Inline Execution** - 在当前会话中按批次执行任务
|
||||
|
||||
选择哪种方式?
|
||||
@@ -1,44 +0,0 @@
|
||||
# Daily Todo 数据库迁移
|
||||
|
||||
## 自动迁移
|
||||
|
||||
SQLAlchemy 会在应用启动时通过 `init_db()` 自动创建所有表,包括 `daily_todos` 表。
|
||||
|
||||
## 手动迁移(如需)
|
||||
|
||||
如果需要手动创建表,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS daily_todos (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
source_detail VARCHAR(500),
|
||||
source_ref_id VARCHAR(36),
|
||||
todo_date VARCHAR(10) NOT NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_date ON daily_todos(user_id, todo_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_todos_user_id ON daily_todos(user_id);
|
||||
```
|
||||
|
||||
## 表说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(36) | 主键,UUID |
|
||||
| user_id | VARCHAR(36) | 所属用户,索引 |
|
||||
| title | VARCHAR(500) | 待办标题 |
|
||||
| is_completed | BOOLEAN | 是否完成,默认 false |
|
||||
| source | VARCHAR(20) | 来源:ai_kanban / ai_chat / manual |
|
||||
| source_detail | VARCHAR(500) | 来源说明文本 |
|
||||
| source_ref_id | VARCHAR(36) | 来源原始ID(看板TaskID或对话ID) |
|
||||
| todo_date | VARCHAR(10) | 所属日期 YYYY-MM-DD |
|
||||
| completed_at | TIMESTAMP | 完成时间 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
||||
# Jarvis Knowledge Brain Phase 1 Task Breakdown
|
||||
|
||||
## Goal
|
||||
Turn the phase-1 knowledge brain blueprint into an execution-ready development task list tied to the current codebase.
|
||||
|
||||
---
|
||||
|
||||
## A. Backend Persistence Tasks
|
||||
|
||||
### A1. Add new brain models
|
||||
Create new SQLAlchemy models under `backend/app/models/`:
|
||||
- `brain_event.py`
|
||||
- `brain_candidate.py`
|
||||
- `brain_memory.py`
|
||||
- `brain_tag.py`
|
||||
- optional link-table definitions in `brain_relations.py` or colocated within the above files
|
||||
|
||||
Core entities to add:
|
||||
- `BrainEvent`
|
||||
- `BrainCandidate`
|
||||
- `BrainMemory`
|
||||
- `BrainTag`
|
||||
- `BrainEventTag`
|
||||
- `BrainCandidateTag`
|
||||
- `BrainMemoryTag`
|
||||
- optional `BrainMemoryEvent`
|
||||
|
||||
Acceptance criteria:
|
||||
- All models inherit from the project base model pattern.
|
||||
- All required enums/status fields are defined.
|
||||
- User ownership and timeline fields exist.
|
||||
- Link tables support tag filtering and source traceability.
|
||||
|
||||
### A2. Register models in model exports
|
||||
Update:
|
||||
- `backend/app/models/__init__.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- New brain models are imported and available during metadata initialization.
|
||||
|
||||
### A3. Add migration / schema evolution support
|
||||
Depending on current project migration approach, add the required DB migration path for the new tables.
|
||||
|
||||
Acceptance criteria:
|
||||
- New tables can be created in local/dev environments without breaking existing tables.
|
||||
- Indexes for `user_id`, status, and date-based access patterns are included.
|
||||
|
||||
### A4. Add Pydantic schemas
|
||||
Create new schema files under `backend/app/schemas/`:
|
||||
- `brain.py`
|
||||
|
||||
Schema groups to add:
|
||||
- overview response
|
||||
- memory list/detail response
|
||||
- candidate list response
|
||||
- tag response
|
||||
- timeline response
|
||||
- manual learning trigger response
|
||||
- memory/tag management payloads
|
||||
|
||||
Acceptance criteria:
|
||||
- Schemas match the intended `/api/brain` response shapes.
|
||||
- Timeline and traceability structures are explicit, not loosely typed blobs.
|
||||
|
||||
---
|
||||
|
||||
## B. Backend Service Tasks
|
||||
|
||||
### B1. Create brain event ingestion service
|
||||
Add:
|
||||
- `backend/app/services/brain_event_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- normalize source records into `BrainEvent`
|
||||
- expose helpers such as:
|
||||
- `record_conversation_event(...)`
|
||||
- `record_document_event(...)`
|
||||
- `record_todo_event(...)`
|
||||
- `record_task_event(...)`
|
||||
- `record_forum_event(...)`
|
||||
|
||||
Acceptance criteria:
|
||||
- Each helper accepts current source-domain inputs without forcing those modules to understand brain internals.
|
||||
- Event creation is idempotent enough to avoid obvious duplicate rows for the same source update.
|
||||
|
||||
### B2. Create brain learning service
|
||||
Add:
|
||||
- `backend/app/services/brain_learning_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- load pending `BrainEvent`s for a given date/user scope
|
||||
- cluster related events
|
||||
- call the LLM to create candidate knowledge
|
||||
- score and dedupe candidates
|
||||
- promote high-confidence candidates into `BrainMemory`
|
||||
- mark processed events and candidate statuses
|
||||
|
||||
Acceptance criteria:
|
||||
- Service supports both manual run and scheduler run.
|
||||
- Promotion/rejection decisions are explicit and testable.
|
||||
- Source event traceability is preserved.
|
||||
|
||||
### B3. Create brain tag service
|
||||
Add:
|
||||
- `backend/app/services/brain_tag_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- attach and score tags
|
||||
- split tags into important vs secondary
|
||||
- update tag scores after learning runs
|
||||
- support cleanup recommendations
|
||||
|
||||
Acceptance criteria:
|
||||
- Important/secondary classification is persisted, not only computed in the UI.
|
||||
- Tag lookups support filtering memories and timeline entries.
|
||||
|
||||
### B4. Create brain retrieval service
|
||||
Add:
|
||||
- `backend/app/services/brain_retrieval_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- retrieve relevant `BrainMemory` records by query
|
||||
- optionally retrieve recent events for recency-sensitive prompts
|
||||
- format results for chat injection and API responses
|
||||
|
||||
Acceptance criteria:
|
||||
- Retrieval has strict limits to prevent prompt bloat.
|
||||
- Results support filtering by tags, source type, and time range.
|
||||
|
||||
### B5. Refactor or extend memory service
|
||||
Update:
|
||||
- `backend/app/services/memory_service.py`
|
||||
|
||||
Tasks:
|
||||
- keep existing summary and `UserMemory` behavior intact
|
||||
- extend `build_memory_context()` to append a `【知识大脑】` block from `BrainRetrievalService`
|
||||
- keep memory context size bounded
|
||||
|
||||
Acceptance criteria:
|
||||
- Existing conversation summary behavior continues to work.
|
||||
- Chat can consume `BrainMemory` without requiring a full prompt architecture rewrite.
|
||||
|
||||
---
|
||||
|
||||
## C. Source Ingestion Integration Tasks
|
||||
|
||||
### C1. Conversation → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/services/agent_service.py`
|
||||
- possibly `backend/app/services/memory_service.py`
|
||||
|
||||
Hook points:
|
||||
- after user message persistence
|
||||
- after assistant response persistence
|
||||
- after summary/memory extraction
|
||||
|
||||
Acceptance criteria:
|
||||
- Important conversation actions produce normalized `BrainEvent`s.
|
||||
- Explicit “remember this” signals are captured as stronger events.
|
||||
|
||||
### C2. Document → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/knowledge_service.py`
|
||||
|
||||
Hook points:
|
||||
- upload success
|
||||
- indexing completion
|
||||
- chunk edit / reindex
|
||||
|
||||
Acceptance criteria:
|
||||
- Document lifecycle milestones become `BrainEvent`s.
|
||||
- Source metadata includes document identity and folder context.
|
||||
|
||||
### C3. Todo → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/todo.py`
|
||||
- `backend/app/services/todo_service.py`
|
||||
|
||||
Hook points:
|
||||
- todo creation
|
||||
- completion
|
||||
- AI-generated todo creation
|
||||
|
||||
Acceptance criteria:
|
||||
- Todo events reflect both planning and completion signals.
|
||||
- AI-generated todos are distinguishable from manual ones.
|
||||
|
||||
### C4. Task/Kanban → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/task.py`
|
||||
|
||||
Hook points:
|
||||
- task creation
|
||||
- status change
|
||||
- completion
|
||||
- priority change
|
||||
|
||||
Acceptance criteria:
|
||||
- Task state changes create meaningful workstream events.
|
||||
- Duplicate writes are avoided on no-op updates.
|
||||
|
||||
### C5. Forum → BrainEvent
|
||||
Update likely files:
|
||||
- `backend/app/routers/forum.py`
|
||||
- optionally `backend/app/services/scheduler_service.py`
|
||||
|
||||
Hook points:
|
||||
- post created
|
||||
- reply created
|
||||
- forum instruction execution
|
||||
|
||||
Acceptance criteria:
|
||||
- Forum posts/replies that matter to project state become brain events.
|
||||
- Source traceability includes whether the event came from a post, reply, or executed instruction.
|
||||
|
||||
---
|
||||
|
||||
## D. Scheduler and Daily Learning Tasks
|
||||
|
||||
### D1. Add daily brain learning job
|
||||
Update:
|
||||
- `backend/app/services/scheduler_service.py`
|
||||
|
||||
Add:
|
||||
- `brain_daily_learning_task()`
|
||||
|
||||
Responsibilities:
|
||||
- run daily for pending events
|
||||
- invoke `BrainLearningService`
|
||||
- log promoted/rejected counts
|
||||
|
||||
Acceptance criteria:
|
||||
- Job is registered in `start_scheduler()`.
|
||||
- Job can run safely when there are no pending events.
|
||||
|
||||
### D2. Add manual trigger path
|
||||
Update or add:
|
||||
- `backend/app/routers/scheduler.py` or the new `backend/app/routers/brain.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- Developers/users can manually run learning for testing.
|
||||
- Trigger returns a useful summary, not only a started flag.
|
||||
|
||||
### D3. Decide scheduler ownership model for phase 1
|
||||
Current scheduler is global. Decide whether phase 1 runs:
|
||||
- for all users in one job, or
|
||||
- per user loop inside one job
|
||||
|
||||
Acceptance criteria:
|
||||
- No hard-coded `user_id="default"` behavior remains in new brain learning flow.
|
||||
- User iteration strategy is explicit.
|
||||
|
||||
---
|
||||
|
||||
## E. Backend API Tasks
|
||||
|
||||
### E1. Add brain router
|
||||
Create:
|
||||
- `backend/app/routers/brain.py`
|
||||
|
||||
Register in:
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/routers/__init__.py` if needed
|
||||
|
||||
### E2. Implement overview endpoint
|
||||
Endpoint:
|
||||
- `GET /api/brain/overview`
|
||||
|
||||
Should return:
|
||||
- active memory count
|
||||
- candidate count
|
||||
- important tag count
|
||||
- recent event count
|
||||
- last learning run info
|
||||
- today’s promoted/rejected summary
|
||||
|
||||
### E3. Implement memory endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/memories`
|
||||
- `GET /api/brain/memory/{id}`
|
||||
- `POST /api/brain/memory/{id}/archive`
|
||||
- `DELETE /api/brain/memory/{id}`
|
||||
- optional `POST /api/brain/memory/{id}/promote` if candidate-to-memory management is exposed here
|
||||
|
||||
Acceptance criteria:
|
||||
- Memory detail shows source traceability and tags.
|
||||
- List endpoint supports pagination/filters needed by UI.
|
||||
|
||||
### E4. Implement candidate endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/candidates`
|
||||
- optional promote/reject endpoints if candidates are user-manageable in phase 1
|
||||
|
||||
Acceptance criteria:
|
||||
- Candidate status and scoring are inspectable.
|
||||
|
||||
### E5. Implement tag endpoints
|
||||
Endpoints:
|
||||
- `GET /api/brain/tags`
|
||||
- `POST /api/brain/tag/{id}/promote`
|
||||
- `POST /api/brain/tag/{id}/demote`
|
||||
- `DELETE /api/brain/tag/{id}`
|
||||
|
||||
Acceptance criteria:
|
||||
- API groups tags by important vs secondary.
|
||||
- Manual cleanup actions are supported.
|
||||
|
||||
### E6. Implement timeline endpoint
|
||||
Endpoint:
|
||||
- `GET /api/brain/timeline`
|
||||
|
||||
Acceptance criteria:
|
||||
- Timeline groups records by day or returns a structure easily grouped by day in UI.
|
||||
- Includes event entries and memory promotion entries.
|
||||
|
||||
### E7. Implement learning trigger endpoint
|
||||
Endpoint:
|
||||
- `POST /api/brain/learn/run`
|
||||
|
||||
Acceptance criteria:
|
||||
- Supports manual learning run for current user or all users, depending on phase-1 policy.
|
||||
- Returns meaningful run stats.
|
||||
|
||||
---
|
||||
|
||||
## F. Chat Integration Tasks
|
||||
|
||||
### F1. Inject knowledge brain into chat context
|
||||
Update:
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
|
||||
Acceptance criteria:
|
||||
- Relevant `BrainMemory` items appear in prompt context.
|
||||
- Context remains concise and bounded.
|
||||
- Existing response flow remains stable.
|
||||
|
||||
### F2. Add retrieval policy guardrails
|
||||
Tasks:
|
||||
- define per-query memory limits
|
||||
- choose when to include recent events
|
||||
- prefer important/high-confidence memories
|
||||
|
||||
Acceptance criteria:
|
||||
- Brain retrieval does not overwhelm standard conversation context.
|
||||
- Time-sensitive answers can still include recent context when needed.
|
||||
|
||||
---
|
||||
|
||||
## G. Frontend Route and Navigation Tasks
|
||||
|
||||
### G1. Introduce a real brain route
|
||||
Update likely files:
|
||||
- `frontend/src/app/router/routes.ts`
|
||||
- `frontend/src/app/navigation/nav.ts`
|
||||
|
||||
Tasks:
|
||||
- add `/brain`
|
||||
- make `知识大脑` point to `/brain`
|
||||
- keep `/graph` available as a subview or secondary route
|
||||
|
||||
Acceptance criteria:
|
||||
- Brain is no longer represented only by the graph page.
|
||||
|
||||
### G2. Define frontend brain API client
|
||||
Add:
|
||||
- `frontend/src/api/brain.ts`
|
||||
|
||||
Methods:
|
||||
- `getOverview`
|
||||
- `getMemories`
|
||||
- `getMemoryDetail`
|
||||
- `getCandidates`
|
||||
- `getTags`
|
||||
- `getTimeline`
|
||||
- `runLearning`
|
||||
- memory/tag management actions
|
||||
|
||||
Acceptance criteria:
|
||||
- API client matches backend router contract.
|
||||
|
||||
---
|
||||
|
||||
## H. Frontend Brain Dashboard Tasks
|
||||
|
||||
### H1. Create new brain page
|
||||
Add:
|
||||
- `frontend/src/pages/brain/index.vue`
|
||||
|
||||
Core page sections:
|
||||
- overview header
|
||||
- important tags panel
|
||||
- secondary tags panel
|
||||
- recent learned knowledge section
|
||||
- timeline section
|
||||
- graph tab/subview entry
|
||||
|
||||
Acceptance criteria:
|
||||
- Page is useful even before graph projection is upgraded.
|
||||
- Dashboard reflects the brain, not just visualized relationships.
|
||||
|
||||
### H2. Add page composable/state logic
|
||||
Add:
|
||||
- `frontend/src/pages/brain/composables/useBrainView.ts`
|
||||
|
||||
Responsibilities:
|
||||
- fetch overview/tags/memories/timeline
|
||||
- manage filters and selected tags
|
||||
- trigger manual learning run
|
||||
- manage loading/error states
|
||||
|
||||
Acceptance criteria:
|
||||
- Page logic stays separate from template complexity.
|
||||
|
||||
### H3. Add memory list/detail components
|
||||
Suggested additions:
|
||||
- `frontend/src/components/brain/BrainMemoryList.vue`
|
||||
- `frontend/src/components/brain/BrainMemoryDetail.vue`
|
||||
- `frontend/src/components/brain/BrainTagPanel.vue`
|
||||
- `frontend/src/components/brain/BrainTimeline.vue`
|
||||
|
||||
Acceptance criteria:
|
||||
- User can inspect why a memory exists.
|
||||
- User can archive/delete memories and promote/demote tags.
|
||||
|
||||
### H4. Reposition graph as brain subview
|
||||
Possible approaches:
|
||||
- keep current `frontend/src/pages/graph/index.vue` but link it from `/brain`
|
||||
- or wrap the graph page as one tab inside the brain page
|
||||
|
||||
Acceptance criteria:
|
||||
- Existing graph functionality remains accessible.
|
||||
- Product framing changes from “brain = graph” to “brain includes graph”.
|
||||
|
||||
---
|
||||
|
||||
## I. Testing Tasks
|
||||
|
||||
### I1. Backend model/service tests
|
||||
Add tests for:
|
||||
- event creation
|
||||
- candidate generation status changes
|
||||
- promotion into `BrainMemory`
|
||||
- tag priority updates
|
||||
- timeline aggregation
|
||||
|
||||
Suggested locations:
|
||||
- `backend/tests/backend/app/services/`
|
||||
- `backend/tests/backend/app/routers/`
|
||||
|
||||
### I2. Retrieval integration tests
|
||||
Add tests for:
|
||||
- memory context injection
|
||||
- retrieval limits
|
||||
- recency-sensitive event inclusion
|
||||
|
||||
### I3. API tests
|
||||
Add tests for:
|
||||
- `/api/brain/overview`
|
||||
- `/api/brain/memories`
|
||||
- `/api/brain/tags`
|
||||
- `/api/brain/timeline`
|
||||
- `/api/brain/learn/run`
|
||||
|
||||
### I4. Frontend tests
|
||||
Add tests for:
|
||||
- brain composable fetch flow
|
||||
- filter behavior
|
||||
- manual learning run UI flow
|
||||
- tag grouping and memory rendering
|
||||
|
||||
---
|
||||
|
||||
## J. Recommended Execution Order
|
||||
|
||||
### Wave 1: Foundation
|
||||
1. A1-A4 persistence and schemas
|
||||
2. B1 brain event service
|
||||
3. E1 add router skeleton
|
||||
|
||||
### Wave 2: Ingestion
|
||||
4. C1-C5 connect all source domains to `BrainEvent`
|
||||
|
||||
### Wave 3: Learning
|
||||
5. B2 brain learning service
|
||||
6. B3 brain tag service
|
||||
7. D1-D3 scheduler/manual learning
|
||||
|
||||
### Wave 4: Retrieval
|
||||
8. B4 brain retrieval service
|
||||
9. B5 memory service integration
|
||||
10. F1-F2 chat injection and guardrails
|
||||
|
||||
### Wave 5: Product surface
|
||||
11. E2-E7 complete `/api/brain` endpoints
|
||||
12. G1-G2 routing + API client
|
||||
13. H1-H4 dashboard and graph repositioning
|
||||
|
||||
### Wave 6: Reliability
|
||||
14. I1-I4 tests and refinement
|
||||
|
||||
---
|
||||
|
||||
## K. Files Most Likely to Change in Phase 1
|
||||
|
||||
### Backend new files
|
||||
- `backend/app/models/brain_event.py`
|
||||
- `backend/app/models/brain_candidate.py`
|
||||
- `backend/app/models/brain_memory.py`
|
||||
- `backend/app/models/brain_tag.py`
|
||||
- `backend/app/schemas/brain.py`
|
||||
- `backend/app/services/brain_event_service.py`
|
||||
- `backend/app/services/brain_learning_service.py`
|
||||
- `backend/app/services/brain_tag_service.py`
|
||||
- `backend/app/services/brain_retrieval_service.py`
|
||||
- `backend/app/routers/brain.py`
|
||||
|
||||
### Backend existing files
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/scheduler_service.py`
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/routers/todo.py`
|
||||
- `backend/app/routers/task.py`
|
||||
- `backend/app/routers/forum.py`
|
||||
- possibly `backend/app/services/document_service.py`
|
||||
- possibly `backend/app/services/knowledge_service.py`
|
||||
|
||||
### Frontend new files
|
||||
- `frontend/src/api/brain.ts`
|
||||
- `frontend/src/pages/brain/index.vue`
|
||||
- `frontend/src/pages/brain/composables/useBrainView.ts`
|
||||
- brain-related components under `frontend/src/components/brain/`
|
||||
|
||||
### Frontend existing files
|
||||
- `frontend/src/app/router/routes.ts`
|
||||
- `frontend/src/app/navigation/nav.ts`
|
||||
- optionally `frontend/src/pages/graph/index.vue`
|
||||
|
||||
---
|
||||
|
||||
## L. Phase 1 “Definition of Done” Checklist
|
||||
- [ ] Brain persistence models exist and are queryable.
|
||||
- [ ] All five core domains emit `BrainEvent`s.
|
||||
- [ ] Daily learning creates `BrainCandidate`s and promotes durable `BrainMemory`s.
|
||||
- [ ] Tag priority is stored and manageable.
|
||||
- [ ] Chat can retrieve relevant brain knowledge.
|
||||
- [ ] `/api/brain` endpoints support dashboard and management actions.
|
||||
- [ ] `/brain` dashboard exists and is usable without relying on the graph page.
|
||||
- [ ] Graph remains available as a secondary/projection view.
|
||||
- [ ] Automated tests cover ingestion, promotion, retrieval, and UI basics.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task Plan: Jarvis Knowledge Brain Phase 1 Blueprint
|
||||
|
||||
## Goal
|
||||
Create a practical phase-1 implementation blueprint for the event-driven knowledge brain, covering backend models, services, scheduler jobs, retrieval integration, APIs, and frontend brain module structure.
|
||||
|
||||
## Phases
|
||||
- [x] Phase 1: Plan and setup
|
||||
- [x] Phase 2: Research/gather information
|
||||
- [x] Phase 3: Draft blueprint
|
||||
- [x] Phase 4: Review and deliver
|
||||
|
||||
## Key Questions
|
||||
1. Which new persistence models are required for an event-driven knowledge brain?
|
||||
2. How should existing conversation, document, todo, task, and forum data flow into the brain?
|
||||
3. What should phase 1 include versus defer to later phases?
|
||||
4. How should the frontend brain module be structured before full graph intelligence exists?
|
||||
|
||||
## Decisions Made
|
||||
- Use an event-driven brain architecture instead of extending the current graph-only flow.
|
||||
- Keep the current graph as a projection/view layer, not the brain source of truth.
|
||||
- Phase 1 should prioritize unified ingestion, candidate generation, long-term memory storage, and retrieval integration.
|
||||
|
||||
## Errors Encountered
|
||||
- None yet.
|
||||
|
||||
## Status
|
||||
**Completed** - Separate implementation plan drafted in `knowledge_ingestion_plan.md` and supporting notes updated.
|
||||
@@ -1,210 +0,0 @@
|
||||
# Knowledge Ingestion Normalization Plan
|
||||
|
||||
## Goal
|
||||
Introduce a unified structured-markdown ingestion pipeline for the knowledge center: MinerU for PDF, existing parsers for DOCX/XLSX/CSV/MD/TXT, persisted normalized content, and lightweight hierarchical chunk semantics.
|
||||
|
||||
## Scope
|
||||
- Backend document parsing and normalization flow
|
||||
- Document persistence model updates
|
||||
- Incremental retrieval/indexing integration
|
||||
- Backfill/reindex strategy for existing documents
|
||||
- Test strategy for parser, router, and migration behavior
|
||||
|
||||
## Non-Goals
|
||||
- Full parent-child chunk graph tables in this phase
|
||||
- Rewriting all chunking logic to markdown-first immediately
|
||||
- Replacing all non-PDF parsers with a new framework
|
||||
- Solving every OCR/image-understanding case in the first pass
|
||||
|
||||
## Architecture Decisions
|
||||
- **PDF parser:** MinerU
|
||||
- **Other parsers:** keep current implementations for DOCX/XLSX/CSV/MD/TXT
|
||||
- **Canonical intermediate representation:** `ParsedDocument + structured_markdown`
|
||||
- **Canonical persisted content:** add `normalized_content` to `documents`
|
||||
- **Hierarchy model:** metadata-based lightweight semantics, not hard foreign-key parent-child chunk tables
|
||||
- **Migration strategy:** additive schema change + on-demand rebuild/reindex
|
||||
|
||||
## Target Flow
|
||||
1. Upload file
|
||||
2. Parse by type
|
||||
- PDF -> MinerU -> normalize to ParsedDocument
|
||||
- Other formats -> current parser -> ParsedDocument
|
||||
3. Render `ParsedDocument` into `structured_markdown`
|
||||
4. Persist document record including `normalized_content`
|
||||
5. Build chunks (initially still from nodes, enriched with lightweight hierarchy metadata)
|
||||
6. Index into vector store
|
||||
7. Serve preview from `normalized_content`
|
||||
|
||||
## Data Model Changes
|
||||
### documents table
|
||||
Add fields:
|
||||
- `normalized_content TEXT NULL`
|
||||
- `normalized_format VARCHAR(50) NULL` (value like `structured_markdown`)
|
||||
- optional later: `normalization_version VARCHAR(50) NULL`
|
||||
|
||||
### document_chunks metadata
|
||||
Enrich chunk metadata with lightweight hierarchy keys:
|
||||
- `chunk_level`
|
||||
- `parent_key`
|
||||
- `block_key`
|
||||
- existing structural metadata remains (`section_path`, `section_title`, `page_number`, `sheet_name`, `row_start`, `row_end`, `content_type`)
|
||||
|
||||
Rationale:
|
||||
- Supports grouped retrieval and contextual reconstruction
|
||||
- Avoids introducing a relational chunk tree prematurely
|
||||
|
||||
## Backend Implementation Steps
|
||||
### Phase 1: Schema and persistence
|
||||
Files:
|
||||
- `backend/app/models/document.py`
|
||||
- `backend/app/database.py`
|
||||
- `backend/app/schemas/document.py`
|
||||
- tests under `backend/tests/backend/app`
|
||||
|
||||
Changes:
|
||||
- Add `normalized_content` and `normalized_format` to `Document`
|
||||
- Extend `ensure_document_columns()` to backfill the new columns for existing databases
|
||||
- Expose `normalized_content` only where needed for preview/read APIs (avoid broad API expansion if not required yet)
|
||||
|
||||
### Phase 2: Introduce structured markdown renderer
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- possibly a new helper module if the renderer gets too large, but prefer keeping it local initially
|
||||
|
||||
Changes:
|
||||
- Add `_render_structured_markdown(parsed: ParsedDocument) -> str`
|
||||
- Keep current per-format parsing functions
|
||||
- After parsing, render once and store into `document.normalized_content`
|
||||
- Add `normalized_format='structured_markdown'`
|
||||
|
||||
Rendering guidance:
|
||||
- headings -> markdown headings
|
||||
- paragraphs/text -> plain markdown paragraphs
|
||||
- CSV/XLSX tables -> markdown table blocks or fenced structured table blocks when tables are too large/wide
|
||||
- PDF page boundaries -> explicit page markers
|
||||
- preserve contextual markers in metadata even if markdown cannot express everything perfectly
|
||||
|
||||
### Phase 3: MinerU integration for PDF
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/pyproject.toml` / lockfile if dependencies are added
|
||||
- config if MinerU requires configurable paths/options
|
||||
|
||||
Changes:
|
||||
- Replace PDF branch with MinerU-backed parsing
|
||||
- Map MinerU output into internal `ParsedNode`/`ParsedDocument`
|
||||
- Preserve page and block order
|
||||
- Represent image blocks as markdown placeholders plus metadata
|
||||
|
||||
Image policy:
|
||||
- First pass: extract image block references, page number, nearby text, and optional captions
|
||||
- Do not perform full image understanding for every image in phase 1
|
||||
- Design metadata so high-value image understanding can be added later
|
||||
|
||||
### Phase 4: Chunk metadata enrichment
|
||||
Files:
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/knowledge_service.py`
|
||||
- tests
|
||||
|
||||
Changes:
|
||||
- Extend `_build_chunks()` to include lightweight hierarchy metadata:
|
||||
- section headings become natural parent keys
|
||||
- row batches / sheet blocks get stable block keys
|
||||
- PDF page/section blocks preserve ordered grouping
|
||||
- Keep current retrieval behavior, but let `_get_related_chunks()` benefit from richer metadata if helpful
|
||||
|
||||
### Phase 5: Preview and rebuild behavior
|
||||
Files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
|
||||
Changes:
|
||||
- `get_document_content()` should prefer `normalized_content`
|
||||
- Fallback to legacy file reading only when normalized content is absent
|
||||
- Rebuild/reindex paths should regenerate normalized content before chunk rebuild/indexing
|
||||
|
||||
### Phase 6: Backfill strategy
|
||||
Approach:
|
||||
- Add a rebuild endpoint or reuse existing reindex flow to backfill `normalized_content`
|
||||
- Existing documents can be migrated lazily:
|
||||
- when opened
|
||||
- when reindexed
|
||||
- or via an admin/batch rebuild command later
|
||||
|
||||
This avoids a risky one-shot migration.
|
||||
|
||||
## Error Handling Changes
|
||||
Current issue:
|
||||
- Upload route can leak parser/dependency problems as generic 500s.
|
||||
|
||||
Changes:
|
||||
- Convert expected parser/business errors to explicit 4xx responses where appropriate
|
||||
- For missing optional parser dependencies, return clear messages such as:
|
||||
- `DOCX parsing dependency missing: python-docx`
|
||||
- `PDF parsing dependency missing/configuration invalid`
|
||||
- Keep true unexpected exceptions as 500s
|
||||
|
||||
Files:
|
||||
- `backend/app/routers/document.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
|
||||
## Testing Plan
|
||||
### Backend unit/integration tests
|
||||
1. Schema migration test for new `documents` columns
|
||||
2. Renderer tests:
|
||||
- markdown headings preserved
|
||||
- section paths retained in metadata
|
||||
- xlsx/csv table blocks rendered predictably
|
||||
- pdf page markers preserved from MinerU mapping
|
||||
3. Upload tests:
|
||||
- successful DOCX/XLSX/CSV/MD/TXT upload stores `normalized_content`
|
||||
- PDF upload stores `normalized_content`
|
||||
- missing dependency returns clear error instead of generic 500 where applicable
|
||||
4. Rebuild/reindex tests:
|
||||
- normalized content regenerated
|
||||
- chunks rebuilt with hierarchy metadata
|
||||
5. Retrieval tests:
|
||||
- related chunk lookup still works with enriched metadata
|
||||
|
||||
### Frontend tests
|
||||
Only if the UI surfaces normalized preview directly in this phase:
|
||||
- knowledge view preview prefers normalized content from API
|
||||
- no regression in upload and refresh persistence behavior
|
||||
|
||||
## Suggested Execution Order
|
||||
1. Add schema fields + migration guard
|
||||
2. Add structured markdown renderer for current parsers
|
||||
3. Store normalized content on upload
|
||||
4. Update content preview to read normalized content first
|
||||
5. Enrich chunk metadata with lightweight hierarchy keys
|
||||
6. Integrate MinerU for PDF
|
||||
7. Add rebuild/backfill path
|
||||
8. Expand tests
|
||||
|
||||
## Risks and Mitigations
|
||||
### Risk: MinerU integration complexity
|
||||
Mitigation:
|
||||
- isolate MinerU to PDF branch only
|
||||
- keep internal ParsedDocument contract stable
|
||||
|
||||
### Risk: markdown rendering loses structure
|
||||
Mitigation:
|
||||
- preserve critical structure in metadata
|
||||
- use explicit block markers for page/sheet/table boundaries
|
||||
|
||||
### Risk: broad retrieval regressions
|
||||
Mitigation:
|
||||
- keep chunking source node-based initially
|
||||
- change one layer at a time
|
||||
|
||||
### Risk: old documents lack normalized content
|
||||
Mitigation:
|
||||
- lazy backfill during preview/reindex
|
||||
|
||||
## Deliverable Recommendation
|
||||
Implement in small PR-sized slices:
|
||||
1. schema + normalized renderer + preview fallback
|
||||
2. hierarchy metadata enrichment
|
||||
3. MinerU PDF integration
|
||||
4. rebuild/backfill tooling
|
||||
@@ -1,215 +0,0 @@
|
||||
# LangSmith 集成实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Jarvis 后端集成 LangSmith 追踪,实现调用追踪、对话历史管理和评估支持。
|
||||
|
||||
**Architecture:** LangGraph 的 `compile()` 方法接受全局 Callbacks 参数,会自动将 Callback 传播到所有节点的 LLM 调用。只需在 Graph 编译时注入 `LangChainTracer`,即可覆盖 Master/Planner/Executor/Librarian/Analyst 所有 5 个节点和工具调用。
|
||||
|
||||
**Tech Stack:** langsmith, langchain-core, langgraph
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总览
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `backend/pyproject.toml` | 添加 langsmith 依赖 |
|
||||
| `backend/.env.example` | 新增 LangSmith 环境变量 |
|
||||
| `backend/app/config.py` | 新增 3 个配置字段 |
|
||||
| `backend/app/config_tracing.py` | 新建,callback 工厂函数 |
|
||||
| `backend/app/agents/graph.py` | 修改 `create_agent_graph()` 支持 callbacks,合并全局 callbacks |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 添加 langsmith 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pyproject.toml`
|
||||
|
||||
- [ ] **Step 1: 添加 langsmith 依赖**
|
||||
|
||||
在 `dependencies` 数组中 `"langchain-ollama>=0.4.0",` 后添加:
|
||||
|
||||
```toml
|
||||
"langchain-ollama>=0.4.0",
|
||||
|
||||
# 可观测性
|
||||
"langsmith>=0.1.0",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 添加环境变量模板
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/.env.example`
|
||||
|
||||
- [ ] **Step 1: 在文件末尾添加 LangSmith 配置节**
|
||||
|
||||
在 `# === 定时任务 ===` 节之前添加:
|
||||
|
||||
```env
|
||||
# === LangSmith 可观测性 ===
|
||||
# 启用 LangSmith 追踪(可选)
|
||||
LANGSMITH_TRACING=false
|
||||
LANGSMITH_API_KEY=your-langsmith-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Config 层添加 LangSmith 配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/config.py`
|
||||
- Create: `backend/app/config_tracing.py` (callback 工厂函数)
|
||||
|
||||
- [ ] **Step 1: 在 Settings 类中添加 3 个配置字段**
|
||||
|
||||
在 `# === NAS 部署 ===` 节之前添加:
|
||||
|
||||
```python
|
||||
# === LangSmith 可观测性 ===
|
||||
LANGSMITH_TRACING: bool = False
|
||||
LANGSMITH_API_KEY: str = ""
|
||||
LANGSMITH_PROJECT: str = "jarvis-agent"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 callback 工厂函数**
|
||||
|
||||
创建新文件 `backend/app/config_tracing.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
LangSmith Tracing 配置
|
||||
提供 Callback 工厂函数,用于 LangGraph 追踪
|
||||
"""
|
||||
|
||||
from langchain_core.callbacks import LangChainTracer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_langsmith_callbacks() -> list:
|
||||
"""
|
||||
根据配置返回 LangSmith Callback 列表
|
||||
未启用时返回空列表
|
||||
"""
|
||||
if not settings.LANGSMITH_TRACING:
|
||||
return []
|
||||
|
||||
if not settings.LANGSMITH_API_KEY:
|
||||
return []
|
||||
|
||||
return [
|
||||
LangChainTracer(
|
||||
project_name=settings.LANGSMITH_PROJECT,
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修改 Graph 接受 Callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/agents/graph.py`
|
||||
|
||||
- [ ] **Step 1: 修改 create_agent_graph() 签名**
|
||||
|
||||
将函数签名从:
|
||||
|
||||
```python
|
||||
def create_agent_graph():
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
def create_agent_graph(callbacks: list | None = None):
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 将 callbacks 传给 compile()**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
return graph.compile()
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
return graph.compile(callbacks=callbacks)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修改 get_agent_graph() 注入默认 callbacks**
|
||||
|
||||
将:
|
||||
|
||||
```python
|
||||
def get_agent_graph():
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
_agent_graph = create_agent_graph()
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
from app.config_tracing import get_langsmith_callbacks
|
||||
|
||||
|
||||
def get_agent_graph(callbacks: list | None = None):
|
||||
"""
|
||||
获取编译好的 Agent 图(单例缓存)。
|
||||
|
||||
Callbacks 在首次编译时固定注入,后续调用忽略 callbacks 参数。
|
||||
如需变更 Callbacks(如修改 LANGCHAIN_PROJECT),需重启服务。
|
||||
|
||||
Args:
|
||||
callbacks: 可选的额外 Callbacks,会与全局 LangSmith Callbacks 合并
|
||||
"""
|
||||
global _agent_graph
|
||||
if _agent_graph is None:
|
||||
langsmith_callbacks = get_langsmith_callbacks()
|
||||
all_callbacks = (callbacks or []) + langsmith_callbacks
|
||||
_agent_graph = create_agent_graph(callbacks=all_callbacks or None)
|
||||
return _agent_graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证集成
|
||||
|
||||
- [ ] **Step 1: 确认依赖安装**
|
||||
|
||||
Run: `cd backend && uv sync`
|
||||
|
||||
- [ ] **Step 2: 启动服务验证无报错**
|
||||
|
||||
Run: `cd backend && uv run uvicorn app.main:app --reload --port 8000`
|
||||
|
||||
- [ ] **Step 3: 配置 .env 并测试**
|
||||
|
||||
在 `.env` 中添加:
|
||||
|
||||
```env
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_API_KEY=your-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
发起一次 Agent 对话,访问 https://smith.langchain.com 确认 trace 出现。
|
||||
|
||||
预期在 Dashboard 中看到:
|
||||
- 5 个节点(master/planner/executor/librarian/analyst)的执行记录
|
||||
- 每个节点的 LLM 输入/输出
|
||||
- 工具调用记录
|
||||
- Token 消耗统计
|
||||
@@ -1,903 +0,0 @@
|
||||
# 注册界面 + 设置界面 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现用户注册界面和设置界面,支持多用户和用户级 LLM 配置
|
||||
|
||||
**Architecture:**
|
||||
- 后端:扩展 User 模型,新建 settings router/service,前端认证依赖现有 auth 机制
|
||||
- 前端:LoginView 添加注册 Tab,新建 SettingsView 页面,复用现有 sci-fi 风格
|
||||
- 数据:User 表增加 JSON 字段存储 llm_config 和 scheduler_config
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Vue 3 + axios + Pinia
|
||||
|
||||
---
|
||||
|
||||
## 文件总览
|
||||
|
||||
```
|
||||
backend/
|
||||
app/
|
||||
models/
|
||||
user.py # 修改:添加 llm_config, scheduler_config 字段
|
||||
schemas/
|
||||
settings.py # 新建:Settings Pydantic schemas
|
||||
routers/
|
||||
settings.py # 新建:settings API router
|
||||
services/
|
||||
settings_service.py # 新建:设置逻辑服务
|
||||
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
settings.ts # 新建:settings API 客户端
|
||||
views/
|
||||
LoginView.vue # 修改:添加注册 Tab
|
||||
SettingsView.vue # 新建:设置页面
|
||||
router/
|
||||
index.ts # 修改:添加 /settings 路由
|
||||
components/
|
||||
SidebarNav.vue # 修改:添加设置菜单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 - User 模型扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/user.py`
|
||||
|
||||
- [ ] **Step 1: 添加 JSON 字段到 User 模型**
|
||||
|
||||
读取现有 User 模型,添加 llm_config 和 scheduler_config 字段:
|
||||
|
||||
```python
|
||||
# 在 User 模型类中添加
|
||||
from sqlalchemy import JSON
|
||||
|
||||
llm_config = Column(JSON, nullable=True) # 用户 LLM 配置
|
||||
scheduler_config = Column(JSON, nullable=True) # 定时任务配置
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 设置默认值**
|
||||
|
||||
确保新用户创建时有默认配置(在 User 模型或 service 层处理)
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/user.py
|
||||
git commit -m "feat(settings): add llm_config and scheduler_config fields to User model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 - Settings Schema 定义
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings schemas**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
# LLM Provider 类型
|
||||
LLMProviderType = Literal["openai", "claude", "ollama", "deepseek", "custom"]
|
||||
LLMType = Literal["chat", "vlm", "embedding", "rerank"]
|
||||
|
||||
# 单个模型配置
|
||||
class LLMModelConfig(BaseModel):
|
||||
provider: LLMProviderType = "openai"
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
|
||||
# LLM 配置输入
|
||||
class LLMConfigIn(BaseModel):
|
||||
chat: Optional[LLMModelConfig] = None
|
||||
vlm: Optional[LLMModelConfig] = None
|
||||
embedding: Optional[LLMModelConfig] = None
|
||||
rerank: Optional[LLMModelConfig] = None
|
||||
|
||||
# 定时任务配置
|
||||
class SchedulerConfigIn(BaseModel):
|
||||
daily_plan_time: Optional[str] = "08:00"
|
||||
forum_scan_interval_minutes: Optional[int] = 30
|
||||
todo_ai_generate_time: Optional[str] = "08:00"
|
||||
enabled: Optional[bool] = True
|
||||
|
||||
# 用户资料更新
|
||||
class ProfileUpdateIn(BaseModel):
|
||||
full_name: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
current_password: Optional[str] = None # 修改密码时需要验证
|
||||
|
||||
# 完整设置输出
|
||||
class SettingsOut(BaseModel):
|
||||
profile: "UserOut" # 引用 auth.py 中的 UserOut
|
||||
llm_config: Optional[dict] = None
|
||||
scheduler_config: Optional[dict] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 测试 LLM 连接请求
|
||||
class LLMTestIn(BaseModel):
|
||||
type: LLMType
|
||||
provider: LLMProviderType
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/settings.py
|
||||
git commit -m "feat(settings): add Pydantic schemas for settings API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 - Settings Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/settings_service.py`
|
||||
|
||||
- [ ] **Step 1: 创建设置服务**
|
||||
|
||||
主要功能:
|
||||
1. `get_user_settings(user_id)` - 获取用户完整设置
|
||||
2. `update_user_profile(user_id, data)` - 更新用户资料
|
||||
3. `update_llm_config(user_id, config)` - 更新 LLM 配置
|
||||
4. `update_scheduler_config(user_id, config)` - 更新定时任务配置
|
||||
5. `test_llm_connection(data)` - 测试 LLM 连接
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import verify_password, get_password_hash
|
||||
from app.services.llm_service import get_llm
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_user_settings(user_id: str, db: AsyncSession) -> dict:
|
||||
"""获取用户完整设置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"profile": user,
|
||||
"llm_config": user.llm_config or {},
|
||||
"scheduler_config": user.scheduler_config or {}
|
||||
}
|
||||
|
||||
async def update_user_profile(
|
||||
user_id: str,
|
||||
db: AsyncSession,
|
||||
full_name: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
current_password: Optional[str] = None
|
||||
) -> User:
|
||||
"""更新用户资料"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
if password:
|
||||
if not current_password or not verify_password(current_password, user.hashed_password):
|
||||
raise ValueError("当前密码错误")
|
||||
user.hashed_password = get_password_hash(password)
|
||||
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_llm_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新 LLM 配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.llm_config or {}
|
||||
# 合并配置
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.llm_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def update_scheduler_config(user_id: str, config: dict, db: AsyncSession) -> dict:
|
||||
"""更新定时任务配置"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
current = user.scheduler_config or {}
|
||||
for key, value in config.items():
|
||||
if value is not None:
|
||||
current[key] = value
|
||||
user.scheduler_config = current
|
||||
await db.commit()
|
||||
return current
|
||||
|
||||
async def test_llm_connection(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str
|
||||
) -> dict:
|
||||
"""测试 LLM 连接"""
|
||||
try:
|
||||
# 根据不同 provider 创建临时 LLM 实例并测试
|
||||
if provider == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or None,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "claude":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
llm = ChatAnthropic(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "ollama":
|
||||
from langchain_ollama import ChatOllama
|
||||
llm = ChatOllama(
|
||||
base_url=base_url or "http://localhost:11434",
|
||||
model=model,
|
||||
timeout=30
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
llm = ChatOpenAI(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url or "https://api.deepseek.com/v1",
|
||||
timeout=30
|
||||
)
|
||||
else:
|
||||
return {"success": False, "error": f"不支持的 provider: {provider}"}
|
||||
|
||||
# 简单测试调用
|
||||
response = await llm.ainvoke([HumanMessage(content="Hi")])
|
||||
return {"success": True, "message": f"连接成功,模型响应: {response.content[:50]}..."}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/settings_service.py
|
||||
git commit -m "feat(settings): add settings service with LLM config management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端 - Settings Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/settings.py`
|
||||
|
||||
- [ ] **Step 1: 创建 settings router**
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.settings import (
|
||||
SettingsOut, ProfileUpdateIn, LLMConfigIn, SchedulerConfigIn, LLMTestIn
|
||||
)
|
||||
from app.services.settings_service import (
|
||||
get_user_settings, update_user_profile, update_llm_config,
|
||||
update_scheduler_config, test_llm_connection
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["设置"])
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
settings = await get_user_settings(current_user.id, db)
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return settings
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
data: ProfileUpdateIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
user = await update_user_profile(
|
||||
current_user.id, db,
|
||||
full_name=data.full_name,
|
||||
password=data.password,
|
||||
current_password=data.current_password
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/llm")
|
||||
async def update_llm(
|
||||
data: LLMConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_llm_config(current_user.id, data.model_dump(exclude_none=True), db)
|
||||
return {"llm_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/llm/test")
|
||||
async def test_llm(
|
||||
data: LLMTestIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await test_llm_connection(
|
||||
provider=data.provider,
|
||||
model=data.model,
|
||||
base_url=data.base_url,
|
||||
api_key=data.api_key
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/scheduler")
|
||||
async def update_scheduler(
|
||||
data: SchedulerConfigIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
config = await update_scheduler_config(
|
||||
current_user.id,
|
||||
data.model_dump(exclude_none=True),
|
||||
db
|
||||
)
|
||||
return {"scheduler_config": config}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 router 到 main.py 和 routers/__init__.py**
|
||||
|
||||
在 `backend/app/routers/__init__.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
```
|
||||
|
||||
在 `backend/app/main.py` 添加:
|
||||
```python
|
||||
from app.routers.settings import router as settings_router
|
||||
# ...
|
||||
app.include_router(settings_router)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/routers/settings.py backend/app/routers/__init__.py backend/app/main.py
|
||||
git commit -m "feat(settings): add settings router with profile, LLM and scheduler endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 - Settings API 客户端
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/settings.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 settings API 客户端**
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export type LLMProvider = 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
|
||||
export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'
|
||||
|
||||
export interface LLMModelConfig {
|
||||
provider: LLMProvider
|
||||
model: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
chat?: LLMModelConfig
|
||||
vlm?: LLMModelConfig
|
||||
embedding?: LLMModelConfig
|
||||
rerank?: LLMModelConfig
|
||||
}
|
||||
|
||||
export interface SchedulerConfig {
|
||||
daily_plan_time?: string
|
||||
forum_scan_interval_minutes?: number
|
||||
todo_ai_generate_time?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileUpdate {
|
||||
full_name?: string
|
||||
password?: string
|
||||
current_password?: string
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
profile: {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
created_at: string
|
||||
}
|
||||
llm_config: LLMConfig
|
||||
scheduler_config: SchedulerConfig
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
// 获取设置
|
||||
get() {
|
||||
return api.get<SettingsResponse>('/api/settings')
|
||||
},
|
||||
|
||||
// 更新资料
|
||||
updateProfile(data: ProfileUpdate) {
|
||||
return api.put('/api/settings/profile', data)
|
||||
},
|
||||
|
||||
// 更新 LLM 配置
|
||||
updateLLM(config: Partial<LLMConfig>) {
|
||||
return api.put('/api/settings/llm', config)
|
||||
},
|
||||
|
||||
// 测试 LLM 连接
|
||||
testLLM(data: { type: LLMType } & LLMModelConfig) {
|
||||
return api.post('/api/settings/llm/test', data)
|
||||
},
|
||||
|
||||
// 更新定时任务配置
|
||||
updateScheduler(config: Partial<SchedulerConfig>) {
|
||||
return api.put('/api/settings/scheduler', config)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/settings.ts
|
||||
git commit -m "feat(settings): add settings API client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 - LoginView 注册功能
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/LoginView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加注册 Tab 和表单**
|
||||
|
||||
在 script setup 中添加:
|
||||
```typescript
|
||||
const isLogin = ref(true)
|
||||
const registerEmail = ref('')
|
||||
const registerPassword = ref('')
|
||||
const registerConfirmPassword = ref('')
|
||||
const registerName = ref('')
|
||||
const isRegistering = ref(false)
|
||||
const registerError = ref('')
|
||||
|
||||
// 密码强度计算
|
||||
function getPasswordStrength(pwd: string): { level: 'weak' | 'medium' | 'strong', text: string } {
|
||||
if (pwd.length < 8) return { level: 'weak', text: '太短' }
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score++
|
||||
if (pwd.length >= 12) score++
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||
if (/\d/.test(pwd)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||
if (score <= 2) return { level: 'weak', text: '弱' }
|
||||
if (score <= 3) return { level: 'medium', text: '中' }
|
||||
return { level: 'strong', text: '强' }
|
||||
}
|
||||
|
||||
const passwordStrength = computed(() => getPasswordStrength(registerPassword.value))
|
||||
|
||||
async function handleRegister() {
|
||||
if (registerPassword.value !== registerConfirmPassword.value) {
|
||||
registerError.value = '两次密码输入不一致'
|
||||
return
|
||||
}
|
||||
if (registerPassword.value.length < 8) {
|
||||
registerError.value = '密码至少需要8个字符'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
registerError.value = ''
|
||||
isRegistering.value = true
|
||||
await authApi.register({
|
||||
email: registerEmail.value,
|
||||
password: registerPassword.value,
|
||||
full_name: registerName.value
|
||||
})
|
||||
// 注册成功后自动登录
|
||||
await auth.login(registerEmail.value, registerPassword.value)
|
||||
router.push('/chat')
|
||||
} catch (e: unknown) {
|
||||
registerError.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '注册失败'
|
||||
} finally {
|
||||
isRegistering.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 template 中添加注册表单(与登录表单并列,用 v-if 切换)
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/LoginView.vue
|
||||
git commit -m "feat(auth): add registration form to LoginView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 - SettingsView 页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 创建设置页面**
|
||||
|
||||
页面结构:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig } from '@/api/settings'
|
||||
import { Save, RotateCcw, Eye, EyeOff, Play } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showApiKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// 用户资料
|
||||
const profile = ref({ email: '', full_name: '' })
|
||||
const newPassword = ref('')
|
||||
|
||||
// LLM 配置
|
||||
const llmConfig = ref<LLMConfig>({
|
||||
chat: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
vlm: { provider: 'openai', model: 'gpt-4o', base_url: '', api_key: '' },
|
||||
embedding: { provider: 'openai', model: 'text-embedding-3-small', base_url: '', api_key: '' },
|
||||
rerank: { provider: 'openai', model: 'bge-reranker-v2', base_url: '', api_key: '' },
|
||||
})
|
||||
|
||||
// 定时任务配置
|
||||
const schedulerConfig = ref<SchedulerConfig>({
|
||||
daily_plan_time: '08:00',
|
||||
forum_scan_interval_minutes: 30,
|
||||
todo_ai_generate_time: '08:00',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await settingsApi.get()
|
||||
profile.value = { ...res.data.profile }
|
||||
llmConfig.value = { ...res.data.llm_config }
|
||||
schedulerConfig.value = { ...res.data.scheduler_config }
|
||||
} catch (e) {
|
||||
console.error('加载设置失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存资料
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateProfile({ full_name: profile.value.full_name })
|
||||
if (newPassword.value) {
|
||||
const currentPwd = prompt('请输入当前密码以确认修改:')
|
||||
if (!currentPwd) {
|
||||
alert('密码修改已取消')
|
||||
return
|
||||
}
|
||||
await settingsApi.updateProfile({
|
||||
password: newPassword.value,
|
||||
current_password: currentPwd
|
||||
})
|
||||
newPassword.value = ''
|
||||
alert('密码修改成功')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 LLM 配置
|
||||
async function saveLLM() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateLLM(llmConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 LLM 连接
|
||||
async function testLLM(type: string, config: LLMModelConfig) {
|
||||
try {
|
||||
const res = await settingsApi.testLLM({ type, ...config })
|
||||
alert(res.data.success ? `成功: ${res.data.message}` : `失败: ${res.data.error}`)
|
||||
} catch (e) {
|
||||
alert('测试连接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存定时任务配置
|
||||
async function saveScheduler() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.updateScheduler(schedulerConfig.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 默认值
|
||||
function getDefaultBaseUrl(provider: string) {
|
||||
switch (provider) {
|
||||
case 'ollama': return 'http://localhost:11434'
|
||||
case 'openai': return 'https://api.openai.com/v1'
|
||||
case 'claude': return 'https://api.anthropic.com'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<span class="header-title">SETTINGS</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="settings-content">
|
||||
<!-- Profile Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">PROFILE</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input v-model="profile.email" type="email" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input v-model="profile.full_name" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input v-model="newPassword" type="password" placeholder="Leave empty to keep current" />
|
||||
</div>
|
||||
<button class="save-btn" @click="saveProfile" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LLM Config Section -->
|
||||
<div v-for="(config, type) in llmConfig" :key="type" class="settings-card">
|
||||
<div class="card-title">{{ type.toUpperCase() }}</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select v-model="config.provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model</label>
|
||||
<input v-model="config.model" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Base URL</label>
|
||||
<input v-model="config.base_url" type="text" :placeholder="getDefaultBaseUrl(config.provider)" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<div class="input-with-toggle">
|
||||
<input v-model="config.api_key" :type="showApiKey[type] ? 'text' : 'password'" />
|
||||
<button @click="showApiKey[type] = !showApiKey[type]" class="toggle-btn">
|
||||
<Eye v-if="!showApiKey[type]" :size="14" />
|
||||
<EyeOff v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="test-btn" @click="testLLM(type, config)">
|
||||
<Play :size="12" /> Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-title">SCHEDULER</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Daily Plan Time</label>
|
||||
<input v-model="schedulerConfig.daily_plan_time" type="time" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Todo AI Generate Time</label>
|
||||
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Forum Scan Interval (minutes)</label>
|
||||
<input v-model.number="schedulerConfig.forum_scan_interval_minutes" type="number" min="5" max="1440" />
|
||||
</div>
|
||||
<div class="form-group toggle-group">
|
||||
<label>Scheduler Enabled</label>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: schedulerConfig.enabled }"
|
||||
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
|
||||
>
|
||||
<span class="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="save-btn" @click="saveScheduler" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Scheduler' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
样式部分复用 AgentView 的 sci-fi 风格,保持一致。
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/SettingsView.vue
|
||||
git commit -m "feat(settings): add SettingsView page with profile, LLM and scheduler config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 路由和侧边栏
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: 添加 /settings 路由**
|
||||
|
||||
在 children 数组中添加:
|
||||
```typescript
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加设置菜单项**
|
||||
|
||||
在 navItems 中添加:
|
||||
```typescript
|
||||
{ name: '设置', path: '/settings', icon: Settings },
|
||||
```
|
||||
|
||||
导入 Settings 图标:
|
||||
```typescript
|
||||
import { Settings } from 'lucide-vue-next'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/router/index.ts frontend/src/components/SidebarNav.vue
|
||||
git commit -m "feat(settings): add /settings route and sidebar menu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 数据库迁移
|
||||
|
||||
- [ ] **Step 1: 创建迁移 SQL**
|
||||
|
||||
由于使用 SQLAlchemy 的 `init_db()` 会在启动时自动创建表,但现有数据库不会自动添加新字段。需要:
|
||||
|
||||
1. 直接在数据库上执行 ALTER TABLE:
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN llm_config TEXT;
|
||||
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
|
||||
```
|
||||
|
||||
2. 或通过 Python 脚本:
|
||||
```python
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN llm_config TEXT'))
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN scheduler_config TEXT'))
|
||||
print('Migration complete')
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交迁移脚本**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-03-20-settings-migration.md
|
||||
git commit -m "feat(settings): add database migration for user settings fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后,验证以下内容:
|
||||
|
||||
1. **注册功能** - 可以通过注册页面创建新账号
|
||||
2. **登录功能** - 新老用户都可以正常登录
|
||||
3. **设置页面** - 可以访问 /settings 页面
|
||||
4. **资料修改** - 用户名、密码可以修改
|
||||
5. **LLM 配置** - 四种模型配置可以保存
|
||||
6. **LLM 测试** - 测试连接功能正常
|
||||
7. **定时任务** - 时间间隔可以修改
|
||||
8. **配置持久化** - 重新登录后配置保留
|
||||
9. **UI 风格** - 设置页面风格与其他页面一致
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序建议
|
||||
|
||||
1. Task 1 → 2 → 3 → 4(后端核心)
|
||||
2. Task 5(前端 API)
|
||||
3. Task 6(LoginView 注册功能)
|
||||
4. Task 7(SettingsView)
|
||||
5. Task 8(路由和侧边栏)
|
||||
6. Task 9(数据库迁移)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,982 +0,0 @@
|
||||
# Stats Dashboard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现统计页面,展示系统健康、对话趋势、知识库、看板、社区和个人洞察等6个Tab的指标数据。
|
||||
|
||||
**Architecture:**
|
||||
- 后端:6个统计API端点,按模块分组
|
||||
- 前端:StatsView.vue 包含 6 个 Tab,使用 ECharts 渲染折线图
|
||||
- 数据聚合:SQL GROUP BY date_trunc('day')
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, ECharts, Vue 3, Element Plus
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ └── stats.py # 新建: 统计 API 路由
|
||||
├── services/
|
||||
│ └── stats_service.py # 新建: 统计服务
|
||||
└── schemas/
|
||||
└── stats.py # 新建: 统计 Schema
|
||||
|
||||
frontend/src/
|
||||
├── api/
|
||||
│ └── stats.ts # 新建: 统计 API
|
||||
├── views/
|
||||
│ └── StatsView.vue # 新建: 统计页面
|
||||
└── router/
|
||||
└── index.ts # 修改: 添加 /stats 路由
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Stats Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/stats.py
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ===== System Health =====
|
||||
class SystemHealth(BaseModel):
|
||||
uptime_seconds: int
|
||||
cpu_percent: float
|
||||
memory_used_mb: float
|
||||
memory_total_mb: float
|
||||
memory_percent: float
|
||||
disk_used_gb: float
|
||||
disk_total_gb: float
|
||||
disk_percent: float
|
||||
active_users_24h: int
|
||||
|
||||
|
||||
# ===== Daily Stats Base =====
|
||||
class DailyStatItem(BaseModel):
|
||||
date: str
|
||||
count: int
|
||||
|
||||
|
||||
class DailyTokenStatItem(BaseModel):
|
||||
date: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
|
||||
|
||||
# ===== Conversation Stats =====
|
||||
class ConversationStats(BaseModel):
|
||||
daily_conversations: list[DailyStatItem]
|
||||
daily_messages: list[DailyStatItem]
|
||||
daily_input_tokens: list[DailyTokenStatItem]
|
||||
daily_output_tokens: list[DailyTokenStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Knowledge Stats =====
|
||||
class KnowledgeStats(BaseModel):
|
||||
daily_new_tags: list[DailyStatItem]
|
||||
daily_documents: list[DailyStatItem]
|
||||
daily_knowledge_queries: list[DailyStatItem]
|
||||
daily_tag_relations: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Kanban Stats =====
|
||||
class KanbanStats(BaseModel):
|
||||
daily_new_tasks: list[DailyStatItem]
|
||||
daily_completed_tasks: list[DailyStatItem]
|
||||
daily_completion_rate: list[DailyStatItem]
|
||||
current_pending_tasks: int
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Community Stats =====
|
||||
class CommunityStats(BaseModel):
|
||||
daily_posts: list[DailyStatItem]
|
||||
daily_replies: list[DailyStatItem]
|
||||
daily_ai_executions: list[DailyStatItem]
|
||||
daily_agent_calls: list[DailyStatItem]
|
||||
totals: dict
|
||||
|
||||
|
||||
# ===== Personal Insights =====
|
||||
class HourlyActivity(BaseModel):
|
||||
hour: int
|
||||
count: int
|
||||
|
||||
|
||||
class TagUsage(BaseModel):
|
||||
tag_path: str
|
||||
usage_count: int
|
||||
|
||||
|
||||
class PersonalInsights(BaseModel):
|
||||
hourly_activity: list[HourlyActivity]
|
||||
top_tags: list[TagUsage]
|
||||
token_trend_percent: float
|
||||
this_month_tokens: int
|
||||
last_month_tokens: int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Stats Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/stats_service.py`
|
||||
|
||||
- [ ] **Step 1: Create stats service**
|
||||
|
||||
```python
|
||||
# backend/app/services/stats_service.py
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.conversation import Conversation, Message
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.forum import ForumPost, ForumReply
|
||||
from app.models.document import Document
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class StatsService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_system_health(self) -> dict:
|
||||
"""获取系统健康指标"""
|
||||
# Uptime (假设进程启动时间)
|
||||
uptime_seconds = int(time.time() - psutil.boot_time())
|
||||
|
||||
# CPU
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
|
||||
# Memory
|
||||
mem = psutil.virtual_memory()
|
||||
memory_used_mb = mem.used / (1024 * 1024)
|
||||
memory_total_mb = mem.total / (1024 * 1024)
|
||||
memory_percent = mem.percent
|
||||
|
||||
# Disk
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_used_gb = disk.used / (1024 * 1024 * 1024)
|
||||
disk_total_gb = disk.total / (1024 * 1024 * 1024)
|
||||
disk_percent = disk.percent
|
||||
|
||||
# Active users (24h)
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
active_users = self.db.query(func.count(func.distinct(User.id))).filter(
|
||||
User.updated_at >= yesterday
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_used_mb": round(memory_used_mb, 1),
|
||||
"memory_total_mb": round(memory_total_mb, 1),
|
||||
"memory_percent": memory_percent,
|
||||
"disk_used_gb": round(disk_used_gb, 1),
|
||||
"disk_total_gb": round(disk_total_gb, 1),
|
||||
"disk_percent": disk_percent,
|
||||
"active_users_24h": active_users,
|
||||
}
|
||||
|
||||
def _get_daily_stats(self, model, date_column, user_id=None, days=30) -> list:
|
||||
"""通用每日统计查询"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
query = self.db.query(
|
||||
func.date(date_column).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(date_column >= cutoff)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(model.user_id == user_id)
|
||||
|
||||
query = query.group_by(func.date(date_column)).order_by(func.date(date_column))
|
||||
results = query.all()
|
||||
return [{"date": str(r.date), "count": r.count} for r in results]
|
||||
|
||||
def get_conversation_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取对话统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Daily conversations
|
||||
daily_conversations = self._get_daily_stats(
|
||||
Conversation, Conversation.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily messages
|
||||
daily_messages = self._get_daily_stats(
|
||||
Message, Message.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Daily tokens (input vs output - approximated by role)
|
||||
input_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'user'
|
||||
)
|
||||
if user_id:
|
||||
input_query = input_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
input_query = input_query.group_by(func.date(Message.created_at))
|
||||
input_results = input_query.all()
|
||||
|
||||
output_query = self.db.query(
|
||||
func.date(Message.created_at).label('date'),
|
||||
func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
|
||||
).filter(
|
||||
Message.created_at >= cutoff,
|
||||
Message.role == 'assistant'
|
||||
)
|
||||
if user_id:
|
||||
output_query = output_query.join(Conversation).filter(Conversation.user_id == user_id)
|
||||
output_query = output_query.group_by(func.date(Message.created_at))
|
||||
output_results = output_query.all()
|
||||
|
||||
daily_input_tokens = [
|
||||
{"date": str(r.date), "input_tokens": r.tokens}
|
||||
for r in input_results
|
||||
]
|
||||
daily_output_tokens = [
|
||||
{"date": str(r.date), "output_tokens": r.tokens}
|
||||
for r in output_results
|
||||
]
|
||||
|
||||
total_conversations = sum(c["count"] for c in daily_conversations)
|
||||
total_messages = sum(m["count"] for m in daily_messages)
|
||||
total_input = sum(t["input_tokens"] for t in daily_input_tokens)
|
||||
total_output = sum(t["output_tokens"] for t in daily_output_tokens)
|
||||
|
||||
return {
|
||||
"daily_conversations": daily_conversations,
|
||||
"daily_messages": daily_messages,
|
||||
"daily_input_tokens": daily_input_tokens,
|
||||
"daily_output_tokens": daily_output_tokens,
|
||||
"totals": {
|
||||
"conversations": total_conversations,
|
||||
"messages": total_messages,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
}
|
||||
}
|
||||
|
||||
def get_knowledge_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取知识库统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tags
|
||||
daily_new_tags = self._get_daily_stats(
|
||||
KGNode, KGNode.created_at, user_id, days
|
||||
)
|
||||
# Filter by tag type if user_id provided
|
||||
if user_id:
|
||||
tag_query = self.db.query(
|
||||
func.date(KGNode.created_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
KGNode.created_at >= cutoff,
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag'
|
||||
).group_by(func.date(KGNode.created_at))
|
||||
daily_new_tags = [{"date": str(r.date), "count": r.count} for r in tag_query.all()]
|
||||
|
||||
# Documents
|
||||
daily_documents = self._get_daily_stats(
|
||||
Document, Document.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Tag relations
|
||||
daily_tag_relations = self._get_daily_stats(
|
||||
KGEdge, KGEdge.created_at, user_id, days
|
||||
)
|
||||
|
||||
return {
|
||||
"daily_new_tags": daily_new_tags,
|
||||
"daily_documents": daily_documents,
|
||||
"daily_knowledge_queries": [], # 需要 Chroma 查询日志
|
||||
"daily_tag_relations": daily_tag_relations,
|
||||
"totals": {
|
||||
"new_tags": sum(t["count"] for t in daily_new_tags),
|
||||
"documents": sum(d["count"] for d in daily_documents),
|
||||
"tag_relations": sum(r["count"] for r in daily_tag_relations),
|
||||
}
|
||||
}
|
||||
|
||||
def get_kanban_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取看板统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# New tasks
|
||||
daily_new_tasks = self._get_daily_stats(
|
||||
Task, Task.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Completed tasks
|
||||
daily_completed = []
|
||||
completed_query = self.db.query(
|
||||
func.date(Task.completed_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Task.completed_at >= cutoff,
|
||||
Task.status == TaskStatus.DONE
|
||||
)
|
||||
if user_id:
|
||||
completed_query = completed_query.filter(Task.user_id == user_id)
|
||||
completed_query = completed_query.group_by(func.date(Task.completed_at))
|
||||
daily_completed = [{"date": str(r.date), "count": r.count} for r in completed_query.all()]
|
||||
|
||||
# Current pending tasks
|
||||
pending_count = self.db.query(func.count(Task.id)).filter(
|
||||
Task.status == TaskStatus.TODO
|
||||
)
|
||||
if user_id:
|
||||
pending_count = pending_count.filter(Task.user_id == user_id)
|
||||
current_pending = pending_count.scalar() or 0
|
||||
|
||||
# Completion rate (daily)
|
||||
daily_new_dict = {d["date"]: d["count"] for d in daily_new_tasks}
|
||||
daily_completed_dict = {d["date"]: d["count"] for d in daily_completed}
|
||||
all_dates = set(daily_new_dict.keys()) | set(daily_completed_dict.keys())
|
||||
daily_completion_rate = []
|
||||
for date in sorted(all_dates):
|
||||
new = daily_new_dict.get(date, 0)
|
||||
completed = daily_completed_dict.get(date, 0)
|
||||
rate = (completed / new * 100) if new > 0 else 0
|
||||
daily_completion_rate.append({"date": date, "rate": round(rate, 1)})
|
||||
|
||||
return {
|
||||
"daily_new_tasks": daily_new_tasks,
|
||||
"daily_completed_tasks": daily_completed,
|
||||
"daily_completion_rate": daily_completion_rate,
|
||||
"current_pending_tasks": current_pending,
|
||||
"totals": {
|
||||
"new_tasks": sum(t["count"] for t in daily_new_tasks),
|
||||
"completed_tasks": sum(c["count"] for c in daily_completed),
|
||||
}
|
||||
}
|
||||
|
||||
def get_community_stats(self, user_id: str = None, days=30) -> dict:
|
||||
"""获取社区统计数据"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Posts
|
||||
daily_posts = self._get_daily_stats(
|
||||
ForumPost, ForumPost.created_at, user_id, days
|
||||
)
|
||||
|
||||
# Replies
|
||||
daily_replies = self._get_daily_stats(
|
||||
ForumReply, ForumReply.created_at, user_id, days
|
||||
)
|
||||
|
||||
# AI executions
|
||||
daily_ai_executions = []
|
||||
ai_query = self.db.query(
|
||||
func.date(ForumPost.updated_at).label('date'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
ForumPost.updated_at >= cutoff,
|
||||
ForumPost.is_executed == True
|
||||
)
|
||||
if user_id:
|
||||
ai_query = ai_query.filter(ForumPost.user_id == user_id)
|
||||
ai_query = ai_query.group_by(func.date(ForumPost.updated_at))
|
||||
daily_ai_executions = [{"date": str(r.date), "count": r.count} for r in ai_query.all()]
|
||||
|
||||
return {
|
||||
"daily_posts": daily_posts,
|
||||
"daily_replies": daily_replies,
|
||||
"daily_ai_executions": daily_ai_executions,
|
||||
"daily_agent_calls": [], # 需要 AgentMessage 表
|
||||
"totals": {
|
||||
"posts": sum(p["count"] for p in daily_posts),
|
||||
"replies": sum(r["count"] for r in daily_replies),
|
||||
"ai_executions": sum(a["count"] for a in daily_ai_executions),
|
||||
}
|
||||
}
|
||||
|
||||
def get_personal_insights(self, user_id: str) -> dict:
|
||||
"""获取个人洞察"""
|
||||
# Hourly activity
|
||||
hourly_query = self.db.query(
|
||||
func.extract('hour', Conversation.created_at).label('hour'),
|
||||
func.count().label('count')
|
||||
).filter(
|
||||
Conversation.user_id == user_id
|
||||
).group_by(func.extract('hour', Conversation.created_at))
|
||||
hourly_results = hourly_query.all()
|
||||
hourly_activity = [{"hour": int(r.hour), "count": r.count} for r in hourly_results]
|
||||
|
||||
# Top tags
|
||||
tag_query = self.db.query(
|
||||
KGNode.properties_["tag_path"].astext.label('tag_path'),
|
||||
func.count(KGEdge.id).label('usage_count')
|
||||
).join(
|
||||
KGEdge, KGEdge.target_id == KGNode.id
|
||||
).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == 'tag',
|
||||
KGEdge.relation_type == 'has_tag'
|
||||
).group_by(
|
||||
KGNode.properties_["tag_path"].astext
|
||||
).order_by(func.count(KGEdge.id).desc()).limit(5)
|
||||
top_tags = [{"tag_path": r.tag_path, "usage_count": r.usage_count} for r in tag_query.all()]
|
||||
|
||||
# Token trend (this month vs last month)
|
||||
now = datetime.utcnow()
|
||||
this_month_start = datetime(now.year, now.month, 1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = datetime(last_month_end.year, last_month_end.month, 1)
|
||||
|
||||
this_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
last_month_tokens = self.db.query(
|
||||
func.coalesce(func.sum(Message.tokens_used), 0)
|
||||
).join(Conversation).filter(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= last_month_start,
|
||||
Message.created_at < this_month_start,
|
||||
Message.role == 'assistant'
|
||||
).scalar() or 0
|
||||
|
||||
token_trend = 0
|
||||
if last_month_tokens > 0:
|
||||
token_trend = round((this_month_tokens - last_month_tokens) / last_month_tokens * 100, 1)
|
||||
|
||||
return {
|
||||
"hourly_activity": hourly_activity,
|
||||
"top_tags": top_tags,
|
||||
"token_trend_percent": token_trend,
|
||||
"this_month_tokens": this_month_tokens,
|
||||
"last_month_tokens": last_month_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Stats Router
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/stats.py`
|
||||
|
||||
- [ ] **Step 1: Create stats router**
|
||||
|
||||
```python
|
||||
# backend/app/routers/stats.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.stats import (
|
||||
SystemHealth,
|
||||
ConversationStats,
|
||||
KnowledgeStats,
|
||||
KanbanStats,
|
||||
CommunityStats,
|
||||
PersonalInsights,
|
||||
)
|
||||
from app.services.stats_service import StatsService
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemHealth)
|
||||
async def get_system_health(db: Session = Depends(get_db)):
|
||||
"""获取系统健康指标"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_system_health()
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationStats)
|
||||
async def get_conversation_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取对话统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_conversation_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/knowledge", response_model=KnowledgeStats)
|
||||
async def get_knowledge_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取知识库统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_knowledge_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/kanban", response_model=KanbanStats)
|
||||
async def get_kanban_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取看板统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_kanban_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/community", response_model=CommunityStats)
|
||||
async def get_community_stats(
|
||||
days: int = 30,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取社区统计数据"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_community_stats(user_id=current_user.id, days=days)
|
||||
|
||||
|
||||
@router.get("/insights", response_model=PersonalInsights)
|
||||
async def get_personal_insights(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取个人洞察"""
|
||||
svc = StatsService(db)
|
||||
return svc.get_personal_insights(user_id=current_user.id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register router in main app**
|
||||
|
||||
在 `backend/app/__init__.py` 或 `main.py` 中添加:
|
||||
```python
|
||||
from app.routers import stats
|
||||
app.include_router(stats.router)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Frontend API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/stats.ts`
|
||||
|
||||
- [ ] **Step 1: Create stats API**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/stats.ts
|
||||
import axios from '@/api'
|
||||
|
||||
export const getSystemHealth = () => axios.get('/stats/system')
|
||||
|
||||
export const getConversationStats = (days = 30) =>
|
||||
axios.get('/stats/conversations', { params: { days } })
|
||||
|
||||
export const getKnowledgeStats = (days = 30) =>
|
||||
axios.get('/stats/knowledge', { params: { days } })
|
||||
|
||||
export const getKanbanStats = (days = 30) =>
|
||||
axios.get('/stats/kanban', { params: { days } })
|
||||
|
||||
export const getCommunityStats = (days = 30) =>
|
||||
axios.get('/stats/community', { params: { days } })
|
||||
|
||||
export const getPersonalInsights = () => axios.get('/stats/insights')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create StatsView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/StatsView.vue`
|
||||
|
||||
- [ ] **Step 1: Create StatsView with 6 tabs**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as statsApi from '@/api/stats'
|
||||
import * as echarts from 'echarts'
|
||||
import {
|
||||
Cpu, HardDrive, MemoryStick, Users, Activity,
|
||||
MessageSquare, BookOpen, CheckSquare, Forum,
|
||||
TrendingUp, Clock, Tag, Zap
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const activeTab = ref('system')
|
||||
const systemHealth = ref<any>(null)
|
||||
const conversationStats = ref<any>(null)
|
||||
const knowledgeStats = ref<any>(null)
|
||||
const kanbanStats = ref<any>(null)
|
||||
const communityStats = ref<any>(null)
|
||||
const personalInsights = ref<any>(null)
|
||||
|
||||
// Format uptime
|
||||
function formatUptime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}d ${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
// Chart refs
|
||||
const convChartRef = ref<HTMLElement>()
|
||||
const knowChartRef = ref<HTMLElement>()
|
||||
const kanbanChartRef = ref<HTMLElement>()
|
||||
const communityChartRef = ref<HTMLElement>()
|
||||
const hourlyChartRef = ref<HTMLElement>()
|
||||
|
||||
onMounted(async () => {
|
||||
const [sys, conv, know, kanban, community, insights] = await Promise.all([
|
||||
statsApi.getSystemHealth(),
|
||||
statsApi.getConversationStats(),
|
||||
statsApi.getKnowledgeStats(),
|
||||
statsApi.getKanbanStats(),
|
||||
statsApi.getCommunityStats(),
|
||||
statsApi.getPersonalInsights(),
|
||||
])
|
||||
systemHealth.value = sys.data
|
||||
conversationStats.value = conv.data
|
||||
knowledgeStats.value = know.data
|
||||
kanbanStats.value = kanban.data
|
||||
communityStats.value = community.data
|
||||
personalInsights.value = insights.data
|
||||
|
||||
// Render charts
|
||||
renderLineChart(convChartRef.value, conv.data)
|
||||
renderLineChart(knowChartRef.value, know.data)
|
||||
renderKanbanChart(kanbanChartRef.value, kanban.data)
|
||||
renderLineChart(communityChartRef.value, community.data)
|
||||
renderHourlyChart(hourlyChartRef.value, insights.data)
|
||||
})
|
||||
|
||||
function renderLineChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = data.daily_conversations?.map((d: any) => d.date) || []
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: Object.keys(data).filter(k => k.startsWith('daily_')) },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: Object.entries(data).filter(([k]) => k.startsWith('daily_')).map(([name, values]) => ({
|
||||
name: name.replace('daily_', ''),
|
||||
type: 'line',
|
||||
data: (values as any[]).map((v: any) => v.count || v.input_tokens || v.output_tokens || 0)
|
||||
}))
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderKanbanChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const dates = [...new Set([
|
||||
...data.daily_new_tasks.map((d: any) => d.date),
|
||||
...data.daily_completed_tasks.map((d: any) => d.date)
|
||||
])].sort()
|
||||
|
||||
option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新建任务', '完成任务'] },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '新建任务', type: 'bar', data: dates.map(d => data.daily_new_tasks.find((t: any) => t.date === d)?.count || 0) },
|
||||
{ name: '完成任务', type: 'bar', data: dates.map(d => data.daily_completed_tasks.find((t: any) => t.date === d)?.count || 0) }
|
||||
]
|
||||
}
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
function renderHourlyChart(el: HTMLElement, data: any) {
|
||||
if (!el || !data) return
|
||||
const chart = echarts.init(el)
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const counts = hours.map(h => data.hourly_activity.find((a: any) => a.hour === h)?.count || 0)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: hours.map(h => `${h}:00`) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data: counts }]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-view">
|
||||
<div class="stats-header">
|
||||
<h1>数据统计</h1>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: System Health -->
|
||||
<el-tab-pane label="系统健康" name="system">
|
||||
<div class="metrics-grid" v-if="systemHealth">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Clock /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ formatUptime(systemHealth.uptime_seconds) }}</span>
|
||||
<span class="metric-label">运行时间</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Cpu /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.cpu_percent }}%</span>
|
||||
<span class="metric-label">CPU 使用率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><MemoryStick /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.memory_percent }}%</span>
|
||||
<span class="metric-label">内存占用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><HardDrive /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.disk_percent }}%</span>
|
||||
<span class="metric-label">磁盘使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon"><Users /></div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ systemHealth.active_users_24h }}</span>
|
||||
<span class="metric-label">活跃用户(24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: Conversations -->
|
||||
<el-tab-pane label="对话统计" name="conversations">
|
||||
<div class="chart-container" ref="convChartRef"></div>
|
||||
<div class="totals-row" v-if="conversationStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.conversations }}</span>
|
||||
<span class="total-label">对话总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.messages }}</span>
|
||||
<span class="total-label">消息总数</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.input_tokens }}</span>
|
||||
<span class="total-label">Input Tokens</span>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ conversationStats.totals.output_tokens }}</span>
|
||||
<span class="total-label">Output Tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: Knowledge -->
|
||||
<el-tab-pane label="知识库" name="knowledge">
|
||||
<div class="chart-container" ref="knowChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: Kanban -->
|
||||
<el-tab-pane label="看板" name="kanban">
|
||||
<div class="chart-container" ref="kanbanChartRef"></div>
|
||||
<div class="totals-row" v-if="kanbanStats">
|
||||
<div class="total-item">
|
||||
<span class="total-value">{{ kanbanStats.current_pending_tasks }}</span>
|
||||
<span class="total-label">待办任务</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 5: Community -->
|
||||
<el-tab-pane label="社区" name="community">
|
||||
<div class="chart-container" ref="communityChartRef"></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 6: Personal Insights -->
|
||||
<el-tab-pane label="个人洞察" name="insights">
|
||||
<div class="insights-grid" v-if="personalInsights">
|
||||
<div class="insight-card">
|
||||
<h3>活跃时段</h3>
|
||||
<div class="chart-small" ref="hourlyChartRef"></div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>常用标签 Top5</h3>
|
||||
<ul class="tag-list">
|
||||
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
|
||||
<Tag /> {{ tag.tag_path }} ({{ tag.usage_count }})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h3>Token 消耗趋势</h3>
|
||||
<div class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
|
||||
<TrendingUp /> {{ personalInsights.token_trend_percent }}%
|
||||
</div>
|
||||
<p>本月 vs 上月</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
padding: 24px;
|
||||
}
|
||||
.stats-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.metric-icon {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.totals-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.total-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.total-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.total-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.insight-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.insight-card h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chart-small {
|
||||
height: 200px;
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.tag-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.trend-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.trend-value.up { color: var(--accent-red); }
|
||||
.trend-value.down { color: var(--accent-green); }
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Route and Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/SidebarNav.vue`
|
||||
|
||||
- [ ] **Step 1: Add route**
|
||||
|
||||
```typescript
|
||||
// frontend/src/router/index.ts
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: () => import('@/views/StatsView.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add navigation item**
|
||||
|
||||
```typescript
|
||||
// frontend/src/components/SidebarNav.vue
|
||||
// Add to navItems array:
|
||||
{ name: '统计', path: '/stats', icon: Activity },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | Stats Schema | `schemas/stats.py` |
|
||||
| 2 | Stats Service | `services/stats_service.py` |
|
||||
| 3 | Stats Router | `routers/stats.py` |
|
||||
| 4 | Frontend API | `api/stats.ts` |
|
||||
| 5 | StatsView Component | `views/StatsView.vue` |
|
||||
| 6 | Route & Navigation | `router/index.ts`, `SidebarNav.vue` |
|
||||
@@ -1,739 +0,0 @@
|
||||
# Tag System Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现一个层级标签系统,标签作为 KGNode(entity_type="tag"),支持 AI 自动生成标签、标签关系网络、内容关联发现。
|
||||
|
||||
**Architecture:**
|
||||
- 标签存储为 KGNode(entity_type="tag"),使用路径格式(tag_path)表示层级
|
||||
- 标签关系通过 KGEdge 表示(parent_of, related_to, synonym_of)
|
||||
- 内容节点通过 `properties_.tag_node_ids` 关联到标签节点
|
||||
- AI 服务自动从内容中提取标签并建立标签关系
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, LLM API
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── models/
|
||||
│ └── knowledge_graph.py # 修改: KGNode 支持 tag 节点
|
||||
├── schemas/
|
||||
│ └── graph.py # 修改: 新增 TagSchema
|
||||
├── services/
|
||||
│ ├── tag_service.py # 新建: 标签提取与管理服务
|
||||
│ └── __init__.py
|
||||
└── routers/
|
||||
└── graph.py # 修改: 新增标签相关 API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend KGNode Properties for Tags
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/knowledge_graph.py:1-33`
|
||||
- Modify: `backend/app/schemas/graph.py:1-35`
|
||||
|
||||
- [ ] **Step 1: Add TagProperties schema**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/graph.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
class TagProperties(BaseModel):
|
||||
tag_path: str = Field(..., description="完整标签路径,如 '编程语言/Python/异步'")
|
||||
short_name: str = Field(..., description="显示名称,如 '异步'")
|
||||
level: int = Field(..., ge=1, description="层级深度,1为顶级")
|
||||
parent_path: str | None = Field(None, description="父路径,如 '编程语言/Python'")
|
||||
description: str | None = Field(None, description="AI生成的标签描述")
|
||||
color: str | None = Field(None, description="标签颜色,如 '#FF5733'")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update KGNodeOut schema**
|
||||
|
||||
```python
|
||||
class KGNodeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
entity_type: str
|
||||
description: str | None
|
||||
properties_: dict | None
|
||||
importance: float
|
||||
created_at: str
|
||||
# 新增:如果是 tag 节点,返回 tag 属性
|
||||
tag_properties: TagProperties | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
def model_post_init(self, __context):
|
||||
if self.entity_type == "tag" and self.properties_:
|
||||
self.tag_properties = TagProperties(**self.properties_)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update KGNode model comment**
|
||||
|
||||
在 `KGNode` 类的 `entity_type` 注释中新增 `"tag"` 选项:
|
||||
```python
|
||||
entity_type = Column(String(100), nullable=False) # person, concept, task, document, chunk, tag
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/knowledge_graph.py backend/app/schemas/graph.py
|
||||
git commit -m "feat: extend KGNode for tag support with path-based hierarchy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Tag Service
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Write tag extraction prompt template**
|
||||
|
||||
```python
|
||||
# backend/app/services/tag_service.py
|
||||
TAG_EXTRACTION_PROMPT = """你是一个知识分类专家。从给定内容中提取标签。
|
||||
|
||||
要求:
|
||||
1. 标签采用层级路径格式,如 "编程语言/Python"、"后端/框架/FastAPI"
|
||||
2. 层级深度 1-4 层,避免过深
|
||||
3. 每个内容提取 3-8 个标签
|
||||
4. 标签应覆盖:主题、技术栈、领域、任务类型等维度
|
||||
|
||||
输出格式(JSON数组):
|
||||
[
|
||||
{{"path": "编程语言/Python", "description": "Python编程语言相关"}},
|
||||
{{"path": "后端/框架/FastAPI", "description": "FastAPI框架相关"}}
|
||||
]
|
||||
|
||||
内容:
|
||||
{content}
|
||||
"""
|
||||
|
||||
TAG_RELATION_PROMPT = """分析以下标签之间的关系,输出 JSON 数组:
|
||||
|
||||
关系类型:
|
||||
- parent_of: 父子关系(上级包含下级)
|
||||
- related_to: 语义相关(但不是父子)
|
||||
- synonym_of: 同义词
|
||||
|
||||
标签列表:
|
||||
{tag_paths}
|
||||
|
||||
输出格式:
|
||||
[
|
||||
{{"source": "标签1", "target": "标签2", "relation": "related_to", "weight": 0.8}},
|
||||
{{"source": "标签1", "target": "标签3", "relation": "parent_of", "weight": 1.0}}
|
||||
]
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write TagService class**
|
||||
|
||||
```python
|
||||
# backend/app/services/tag_service.py
|
||||
import json
|
||||
import re
|
||||
from typing import Annotated
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.knowledge_graph import KGNode, KGEdge
|
||||
|
||||
class TagService:
|
||||
def __init__(self, db: Session, llm_client):
|
||||
self.db = db
|
||||
self.llm_client = llm_client
|
||||
|
||||
def extract_tags_from_content(
|
||||
self, content: str, user_id: str
|
||||
) -> list[dict]:
|
||||
"""从内容中提取标签"""
|
||||
response = self.llm_client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个知识分类专家。"},
|
||||
{"role": "user", "content": TAG_EXTRACTION_PROMPT.format(content=content)}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result.get("tags", [])
|
||||
|
||||
def parse_tag_path(self, path: str) -> tuple[str, int, str | None]:
|
||||
"""解析标签路径,返回 (short_name, level, parent_path)"""
|
||||
parts = path.strip("/").split("/")
|
||||
short_name = parts[-1]
|
||||
level = len(parts)
|
||||
parent_path = "/".join(parts[:-1]) if level > 1 else None
|
||||
return short_name, level, parent_path
|
||||
|
||||
def get_or_create_tag_node(
|
||||
self, tag_info: dict, user_id: str
|
||||
) -> KGNode:
|
||||
"""获取或创建标签节点"""
|
||||
path = tag_info["path"]
|
||||
existing = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.properties_["tag_path"].astext == path
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
short_name, level, parent_path = self.parse_tag_path(path)
|
||||
|
||||
node = KGNode(
|
||||
user_id=user_id,
|
||||
name=short_name,
|
||||
entity_type="tag",
|
||||
description=tag_info.get("description"),
|
||||
properties_={
|
||||
"tag_path": path,
|
||||
"short_name": short_name,
|
||||
"level": level,
|
||||
"parent_path": parent_path,
|
||||
"description": tag_info.get("description"),
|
||||
"color": tag_info.get("color"),
|
||||
},
|
||||
importance=0.5
|
||||
)
|
||||
self.db.add(node)
|
||||
self.db.flush()
|
||||
return node
|
||||
|
||||
def ensure_parent_tags(self, path: str, user_id: str) -> list[KGNode]:
|
||||
"""确保父路径标签存在"""
|
||||
parts = path.strip("/").split("/")
|
||||
nodes = []
|
||||
for i in range(1, len(parts)):
|
||||
parent_path = "/".join(parts[:i])
|
||||
tag_info = {"path": parent_path, "description": None}
|
||||
node = self.get_or_create_tag_node(tag_info, user_id)
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def create_tag_relations(
|
||||
self, tag_paths: list[str], user_id: str
|
||||
) -> list[KGEdge]:
|
||||
"""分析并创建标签之间的关系边"""
|
||||
# 构建标签节点映射
|
||||
path_to_node = {}
|
||||
for path in tag_paths:
|
||||
node = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.properties_["tag_path"].astext == path,
|
||||
KGNode.entity_type == "tag"
|
||||
).first()
|
||||
if node:
|
||||
path_to_node[path] = node
|
||||
|
||||
# 调用 LLM 分析关系
|
||||
response = self.llm_client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个知识图谱专家。"},
|
||||
{"role": "user", "content": TAG_RELATION_PROMPT.format(tag_paths=json.dumps(tag_paths))}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
relations = result.get("relations", [])
|
||||
|
||||
edges = []
|
||||
for rel in relations:
|
||||
source_node = path_to_node.get(rel["source"])
|
||||
target_node = path_to_node.get(rel["target"])
|
||||
if source_node and target_node:
|
||||
# 检查边是否已存在
|
||||
existing = self.db.query(KGEdge).filter(
|
||||
KGEdge.source_id == source_node.id,
|
||||
KGEdge.target_id == target_node.id
|
||||
).first()
|
||||
if not existing:
|
||||
edge = KGEdge(
|
||||
source_id=source_node.id,
|
||||
target_id=target_node.id,
|
||||
relation_type=rel["relation"],
|
||||
weight=rel.get("weight", 0.5)
|
||||
)
|
||||
self.db.add(edge)
|
||||
edges.append(edge)
|
||||
|
||||
self.db.flush()
|
||||
return edges
|
||||
|
||||
def tag_content(
|
||||
self, content: str, user_id: str, content_node: KGNode
|
||||
) -> list[KGNode]:
|
||||
"""为内容节点打标签"""
|
||||
# 1. 提取标签
|
||||
tag_infos = self.extract_tags_from_content(content, user_id)
|
||||
tag_paths = [t["path"] for t in tag_infos]
|
||||
|
||||
# 2. 创建/获取标签节点(包含父路径)
|
||||
tag_nodes = []
|
||||
for tag_info in tag_infos:
|
||||
node = self.get_or_create_tag_node(tag_info, user_id)
|
||||
tag_nodes.append(node)
|
||||
# 确保父标签存在
|
||||
self.ensure_parent_tags(tag_info["path"], user_id)
|
||||
|
||||
# 3. 更新内容节点的 tag_node_ids
|
||||
tag_node_ids = [n.id for n in tag_nodes]
|
||||
current_tag_ids = content_node.properties_.get("tag_node_ids", [])
|
||||
content_node.properties_["tag_node_ids"] = list(set(current_tag_ids + tag_node_ids))
|
||||
|
||||
# 4. 分析并创建标签关系
|
||||
if len(tag_paths) >= 2:
|
||||
self.create_tag_relations(tag_paths, user_id)
|
||||
|
||||
self.db.commit()
|
||||
return tag_nodes
|
||||
|
||||
def get_related_content(
|
||||
self, tag_node_ids: list[str], user_id: str, limit: int = 10
|
||||
) -> list[tuple[KGNode, float]]:
|
||||
"""通过标签找相关内容"""
|
||||
# 找所有关联到这些标签的内容节点
|
||||
edges = self.db.query(KGEdge).filter(
|
||||
KGEdge.target_id.in_(tag_node_ids),
|
||||
KGEdge.relation_type == "has_tag"
|
||||
).all()
|
||||
|
||||
# 统计共现次数作为权重
|
||||
content_weights: dict[str, float] = {}
|
||||
for edge in edges:
|
||||
content_weights[edge.source_id] = content_weights.get(edge.source_id, 0) + edge.weight
|
||||
|
||||
# 获取内容节点
|
||||
content_ids = list(content_weights.keys())
|
||||
content_nodes = self.db.query(KGNode).filter(
|
||||
KGNode.id.in_(content_ids),
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"])
|
||||
).all()
|
||||
|
||||
return [(node, content_weights[node.id]) for node in content_nodes]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/tag_service.py
|
||||
git commit -m "feat: add TagService for AI-powered tag extraction and management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Tag API Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/graph.py`
|
||||
|
||||
- [ ] **Step 1: Read existing router**
|
||||
|
||||
```bash
|
||||
cat backend/app/routers/graph.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add tag-related schemas**
|
||||
|
||||
```python
|
||||
# 在 graph.py 末尾添加
|
||||
class TagExtractRequest(BaseModel):
|
||||
content: str = Field(..., min_length=10)
|
||||
user_id: str
|
||||
|
||||
class TagExtractResponse(BaseModel):
|
||||
tags: list[TagProperties]
|
||||
tag_count: int
|
||||
|
||||
class TagRelationRequest(BaseModel):
|
||||
tag_paths: list[str] = Field(..., min_length=2)
|
||||
user_id: str
|
||||
|
||||
class RelatedContentRequest(BaseModel):
|
||||
tag_ids: list[str]
|
||||
user_id: str
|
||||
limit: int = 10
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add tag endpoints**
|
||||
|
||||
```python
|
||||
@router.post("/tags/extract", response_model=TagExtractResponse)
|
||||
async def extract_tags(request: TagExtractRequest, db: Session = Depends(get_db)):
|
||||
"""从内容中提取标签(不保存到节点)"""
|
||||
# 需要注入 LLM client
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
tag_infos = tag_service.extract_tags_from_content(request.content, request.user_id)
|
||||
tags = []
|
||||
for t in tag_infos:
|
||||
short_name, level, parent_path = tag_service.parse_tag_path(t["path"])
|
||||
tags.append(TagProperties(
|
||||
tag_path=t["path"],
|
||||
short_name=short_name,
|
||||
level=level,
|
||||
parent_path=parent_path,
|
||||
description=t.get("description")
|
||||
))
|
||||
|
||||
return TagExtractResponse(tags=tags, tag_count=len(tags))
|
||||
|
||||
@router.post("/tags/content/{node_id}", response_model=TagExtractResponse)
|
||||
async def tag_content_node(
|
||||
node_id: str,
|
||||
request: TagExtractRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""为内容节点打标签"""
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
node = db.query(KGNode).filter(KGNode.id == node_id).first()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
tag_nodes = tag_service.tag_content(request.content, request.user_id, node)
|
||||
tags = []
|
||||
for n in tag_nodes:
|
||||
props = n.properties_ or {}
|
||||
tags.append(TagProperties(
|
||||
tag_path=props.get("tag_path", n.name),
|
||||
short_name=n.name,
|
||||
level=props.get("level", 1),
|
||||
parent_path=props.get("parent_path"),
|
||||
description=n.description
|
||||
))
|
||||
|
||||
return TagExtractResponse(tags=tags, tag_count=len(tags))
|
||||
|
||||
@router.get("/tags/{user_id}", response_model=list[KGNodeOut])
|
||||
async def get_user_tags(user_id: str, db: Session = Depends(get_db)):
|
||||
"""获取用户的所有标签"""
|
||||
nodes = db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type == "tag"
|
||||
).order_by(KGNode.properties_["level"].astext).all()
|
||||
|
||||
return nodes
|
||||
|
||||
@router.get("/tags/{tag_id}/related", response_model=list[KGNodeOut])
|
||||
async def get_related_tags(tag_id: str, db: Session = Depends(get_db)):
|
||||
"""获取标签的关联标签"""
|
||||
# 获取该标签的所有关联边
|
||||
edges = db.query(KGEdge).filter(
|
||||
or_(KGEdge.source_id == tag_id, KGEdge.target_id == tag_id),
|
||||
KGEdge.relation_type.in_(["related_to", "synonym_of"])
|
||||
).all()
|
||||
|
||||
related_ids = set()
|
||||
for e in edges:
|
||||
if e.source_id == tag_id:
|
||||
related_ids.add(e.target_id)
|
||||
else:
|
||||
related_ids.add(e.source_id)
|
||||
|
||||
if not related_ids:
|
||||
return []
|
||||
|
||||
nodes = db.query(KGNode).filter(KGNode.id.in_(related_ids)).all()
|
||||
return nodes
|
||||
|
||||
@router.post("/content/related", response_model=list[KGNodeOut])
|
||||
async def get_related_content(
|
||||
request: RelatedContentRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""通过标签找相关内容"""
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
results = tag_service.get_related_content(request.tag_ids, request.user_id, request.limit)
|
||||
nodes = [r[0] for r in results]
|
||||
return nodes
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/routers/graph.py
|
||||
git commit -m "feat: add tag API routes for extraction and retrieval"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add has_tag Edge Creation
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Add has_tag edge when tagging content**
|
||||
|
||||
在 `tag_content` 方法中,添加创建 `has_tag` 边的逻辑:
|
||||
|
||||
```python
|
||||
# 在 `tag_content` 方法中,`更新内容节点的 tag_node_ids` 之前添加:
|
||||
|
||||
# 创建 has_tag 边
|
||||
for tag_node in tag_nodes:
|
||||
existing_edge = self.db.query(KGEdge).filter(
|
||||
KGEdge.source_id == content_node.id,
|
||||
KGEdge.target_id == tag_node.id,
|
||||
KGEdge.relation_type == "has_tag"
|
||||
).first()
|
||||
if not existing_edge:
|
||||
edge = KGEdge(
|
||||
source_id=content_node.id,
|
||||
target_id=tag_node.id,
|
||||
relation_type="has_tag",
|
||||
weight=1.0
|
||||
)
|
||||
self.db.add(edge)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/tag_service.py
|
||||
git commit -m "fix: create has_tag edges when tagging content"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Write Unit Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/backend/app/services/test_tag_service.py`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.services.tag_service import TagService
|
||||
|
||||
class TestTagService:
|
||||
def test_parse_tag_path_single_level(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("Python")
|
||||
|
||||
assert short_name == "Python"
|
||||
assert level == 1
|
||||
assert parent_path is None
|
||||
|
||||
def test_parse_tag_path_nested(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("编程语言/Python/异步")
|
||||
|
||||
assert short_name == "异步"
|
||||
assert level == 3
|
||||
assert parent_path == "编程语言/Python"
|
||||
|
||||
def test_parse_tag_path_strips_slashes(self):
|
||||
service = TagService(db=MagicMock(), llm_client=MagicMock())
|
||||
short_name, level, parent_path = service.parse_tag_path("/后端/框架/")
|
||||
|
||||
assert short_name == "框架"
|
||||
assert level == 2
|
||||
assert parent_path == "后端"
|
||||
|
||||
@patch('app.services.tag_service.KGNode')
|
||||
@patch('app.services.tag_service.KGEdge')
|
||||
def test_get_or_create_tag_node_creates_new(self, mock_edge, mock_node):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
service = TagService(db=mock_db, llm_client=MagicMock())
|
||||
tag_info = {"path": "Python", "description": "Python语言"}
|
||||
|
||||
result = service.get_or_create_tag_node(tag_info, "user_123")
|
||||
|
||||
assert result is not None
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.flush.assert_called_once()
|
||||
|
||||
@patch('app.services.tag_service.KGNode')
|
||||
def test_get_or_create_tag_node_returns_existing(self, mock_node):
|
||||
mock_db = MagicMock()
|
||||
mock_existing = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_existing
|
||||
|
||||
service = TagService(db=mock_db, llm_client=MagicMock())
|
||||
tag_info = {"path": "Python", "description": "Python语言"}
|
||||
|
||||
result = service.get_or_create_tag_node(tag_info, "user_123")
|
||||
|
||||
assert result == mock_existing
|
||||
mock_db.add.assert_not_called()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/backend/app/services/test_tag_service.py -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/backend/app/services/test_tag_service.py
|
||||
git commit -m "test: add unit tests for TagService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Scheduled Tag Generation Job
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/scheduler_service.py:1-211`
|
||||
- Modify: `backend/app/routers/scheduler.py:1-41`
|
||||
|
||||
- [ ] **Step 1: Add async tag service method**
|
||||
|
||||
在 `TagService` 中添加增量打标签方法:
|
||||
|
||||
```python
|
||||
# 在 tag_service.py 添加
|
||||
|
||||
async def tag_incremental_content(self, user_id: str, days: int = 1) -> dict:
|
||||
"""
|
||||
增量打标签 - 只对最近新增/更新的内容节点打标签
|
||||
- days: 追溯最近多少天内的内容
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# 查找尚未打标签的内容节点(tag_node_ids 为空或未设置)
|
||||
content_nodes = self.db.query(KGNode).filter(
|
||||
KGNode.user_id == user_id,
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"]),
|
||||
KGNode.updated_at >= cutoff_date
|
||||
).all()
|
||||
|
||||
# 过滤掉已有标签的节点
|
||||
untagged = [
|
||||
n for n in content_nodes
|
||||
if not n.properties_.get("tag_node_ids")
|
||||
]
|
||||
|
||||
tagged_count = 0
|
||||
for node in untagged:
|
||||
content = node.description or ""
|
||||
try:
|
||||
self.tag_content(content, user_id, node)
|
||||
tagged_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"标签化节点 {node.id} 失败: {e}")
|
||||
|
||||
return {"total": len(untagged), "tagged": tagged_count}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add scheduled job function**
|
||||
|
||||
在 `scheduler_service.py` 中添加:
|
||||
|
||||
```python
|
||||
async def tag_generation_task():
|
||||
"""
|
||||
每日凌晨 00:00 增量标签生成任务
|
||||
- 扫描最近 24 小时新增/更新的内容
|
||||
- AI 自动提取标签
|
||||
- 建立标签关系网络
|
||||
"""
|
||||
logger.info("[Scheduler] 开始执行每日标签生成...")
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
from app.services.tag_service import TagService
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
llm_client = get_llm_client()
|
||||
tag_service = TagService(db, llm_client)
|
||||
|
||||
# 获取所有有内容的用户
|
||||
result = await db.execute(
|
||||
select(KGNode.user_id).distinct().where(
|
||||
KGNode.entity_type.in_(["conversation", "document", "chunk"])
|
||||
)
|
||||
)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
total_tagged = 0
|
||||
for user_id in user_ids:
|
||||
# 同步调用增量打标签
|
||||
sync_tag_service = TagService(db, llm_client)
|
||||
result = sync_tag_service.tag_incremental_content(user_id, days=1)
|
||||
total_tagged += result["tagged"]
|
||||
|
||||
logger.info(f"[Scheduler] 每日标签生成完成,共标签化 {total_tagged} 个内容节点")
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] 每日标签生成失败: {e}")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the scheduled job**
|
||||
|
||||
在 `start_scheduler()` 函数中添加:
|
||||
|
||||
```python
|
||||
# 每天凌晨 00:00 生成标签
|
||||
scheduler.add_job(
|
||||
tag_generation_task,
|
||||
CronTrigger(hour=0, minute=0, timezone="Asia/Shanghai"),
|
||||
id="tag_generation",
|
||||
name="每日标签生成",
|
||||
replace_existing=True,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update scheduler router**
|
||||
|
||||
在 `routers/scheduler.py` 的 `job_map` 中添加:
|
||||
|
||||
```python
|
||||
job_map = {
|
||||
"daily_task_analysis": daily_task_analysis,
|
||||
"forum_scan": forum_scan_task,
|
||||
"graph_rebuild": graph_rebuild_task,
|
||||
"tag_generation": tag_generation_task, # 新增
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/scheduler_service.py backend/app/routers/scheduler.py
|
||||
git commit -m "feat: add daily scheduled tag generation at midnight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | Extend KGNode for tag support | `models/knowledge_graph.py`, `schemas/graph.py` |
|
||||
| 2 | Create TagService for AI tag extraction | `services/tag_service.py` |
|
||||
| 3 | Add tag API routes | `routers/graph.py` |
|
||||
| 4 | Fix has_tag edge creation | `services/tag_service.py` |
|
||||
| 5 | Write unit tests | `tests/backend/app/services/test_tag_service.py` |
|
||||
| 6 | Add scheduled tag generation (00:00) | `services/scheduler_service.py`, `routers/scheduler.py` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,941 +0,0 @@
|
||||
# 知识库文件夹分层实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD、级联删除
|
||||
|
||||
**Architecture:** 使用邻接表模式(parent_id)存储文件夹层级,通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 Folder 模型
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/folder.py`
|
||||
- Test: `backend/app/models/test_folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Folder 模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/folder.py
|
||||
from sqlalchemy import Column, String, ForeignKey, UniqueConstraint
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Folder(BaseModel):
|
||||
__tablename__ = "folders"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'),
|
||||
)
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建测试文件验证模型**
|
||||
|
||||
```python
|
||||
# backend/app/models/test_folder.py
|
||||
import pytest
|
||||
from app.models.folder import Folder
|
||||
|
||||
def test_folder_model_creation():
|
||||
folder = Folder(user_id="test-user", name="Test Folder")
|
||||
assert folder.name == "Test Folder"
|
||||
assert folder.parent_id is None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 Folder Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/folder.py`
|
||||
|
||||
- [ ] **Step 1: 创建 Pydantic schemas**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/folder.py
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
forbidden = '/\\*?:'
|
||||
for c in forbidden:
|
||||
if c in v:
|
||||
raise ValueError(f'Folder name cannot contain: {forbidden}')
|
||||
return v
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
class FolderOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class FolderTreeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
parent_id: Optional[str]
|
||||
children: List["FolderTreeOut"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
# 递归模型需要 forward ref
|
||||
FolderTreeOut.model_rebuild()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 创建文件夹路由
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/folder.py`
|
||||
|
||||
- [ ] **Step 1: 实现文件夹 CRUD 路由**
|
||||
|
||||
```python
|
||||
# backend/app/routers/folder.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||
|
||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||
"""递归构建文件夹树"""
|
||||
tree = []
|
||||
for folder in folders:
|
||||
if folder.parent_id == parent_id:
|
||||
children = build_folder_tree(folders, folder.id)
|
||||
tree.append(FolderTreeOut(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
parent_id=folder.parent_id,
|
||||
children=children
|
||||
))
|
||||
return tree
|
||||
|
||||
@router.get("", response_model=List[FolderTreeOut])
|
||||
async def get_folders(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取用户的完整文件夹树"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.user_id == current_user.id)
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
return build_folder_tree(list(folders))
|
||||
|
||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
folder_data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""创建文件夹"""
|
||||
# 验证父文件夹存在且属于当前用户
|
||||
if folder_data.parent_id:
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||
|
||||
# 检查同名文件夹
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.parent_id == folder_data.parent_id,
|
||||
Folder.name == folder_data.name
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="同名文件夹已存在")
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
name=folder_data.name,
|
||||
parent_id=folder_data.parent_id
|
||||
)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.put("/{folder_id}", response_model=FolderOut)
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
folder_data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""重命名文件夹"""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
folder.name = folder_data.name
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""删除文件夹(级联删除文档)"""
|
||||
from app.models.document import Document
|
||||
from app.services.knowledge_service import KnowledgeService
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
|
||||
async def delete_recursive(fid: str):
|
||||
# 删除子文件夹(先递归)
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == fid)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_recursive(child.id)
|
||||
|
||||
# 删除文档
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == fid)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
knowledge_service = KnowledgeService(db, current_user.id)
|
||||
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 删除文件夹本身
|
||||
folder_to_delete = await db.get(Folder, fid)
|
||||
if folder_to_delete:
|
||||
await db.delete(folder_to_delete)
|
||||
|
||||
await delete_recursive(folder_id)
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改 Document 模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/document.py:14`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 外键**
|
||||
|
||||
```python
|
||||
# backend/app/models/document.py 第14行后添加
|
||||
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改 Document 路由和服务
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/document.py`
|
||||
- Modify: `backend/app/services/document_service.py`
|
||||
|
||||
- [ ] **Step 1: 修改 Document 路由**
|
||||
|
||||
在 `routers/document.py` 中:
|
||||
- GET `/api/documents` 添加 `folder_id` 可选查询参数
|
||||
- POST `/api/documents` 添加 `folder_id` 表单字段
|
||||
|
||||
```python
|
||||
# GET /api/documents 修改
|
||||
@router.get("")
|
||||
async def list_documents(
|
||||
folder_id: Optional[str] = None, # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(Document).where(Document.user_id == current_user.id)
|
||||
if folder_id:
|
||||
query = query.where(Document.folder_id == folder_id)
|
||||
# ... 其余不变
|
||||
|
||||
# POST /api/documents 修改
|
||||
@router.post("")
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: Optional[str] = Form(None), # 新增
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# ... 文件处理逻辑 ...
|
||||
doc = await doc_svc.upload_document(
|
||||
user_id=current_user.id,
|
||||
file=file,
|
||||
folder_id=folder_id # 传递 folder_id
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修改 DocumentService.upload_document**
|
||||
|
||||
```python
|
||||
# backend/app/services/document_service.py
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
user_id: str,
|
||||
file: UploadFile,
|
||||
folder_id: str | None = None, # 新增
|
||||
) -> Document:
|
||||
# ... 文件保存逻辑 ...
|
||||
|
||||
# 获取文件夹路径(用于 ChromaDB metadata)
|
||||
folder_path = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
|
||||
# 创建文档记录
|
||||
doc = Document(
|
||||
user_id=user_id,
|
||||
title=filename.rsplit('.', 1)[0],
|
||||
filename=filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
file_path=file_path,
|
||||
folder_id=folder_id, # 新增
|
||||
)
|
||||
# ... 其余逻辑 ...
|
||||
return doc
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
folders = await self.db.execute(
|
||||
select(Folder).where(Folder.user_id == self.user_id)
|
||||
)
|
||||
folder_map = {f.id: f for f in folders.scalars().all()}
|
||||
|
||||
path_parts = []
|
||||
current_id = folder_id
|
||||
while current_id:
|
||||
folder = folder_map.get(current_id)
|
||||
if not folder:
|
||||
break
|
||||
path_parts.insert(0, folder.name)
|
||||
current_id = folder.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts) if path_parts else None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 修改 Knowledge Service
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/knowledge_service.py`
|
||||
|
||||
- [ ] **Step 1: retrieve 方法添加 folder_id 参数**
|
||||
|
||||
```python
|
||||
# backend/app/services/knowledge_service.py
|
||||
|
||||
# 在 index_document 方法中添加 folder_path 到 metadata
|
||||
async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None):
|
||||
"""将文档 chunks 向量化存入 ChromaDB"""
|
||||
# ... 现有代码 ...
|
||||
|
||||
metadatas = [
|
||||
{
|
||||
"document_id": doc.id,
|
||||
"document_title": doc.title,
|
||||
"chunk_index": chunk.chunk_index,
|
||||
"file_type": doc.file_type,
|
||||
"folder_path": folder_path or "", # 新增
|
||||
}
|
||||
for chunk in chunks
|
||||
]
|
||||
# ... 其余不变
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
folder_id: str | None = None, # 新增
|
||||
top_k: int = 5,
|
||||
use_rerank: bool = True,
|
||||
) -> list[SearchResult]:
|
||||
"""混合检索 + Rerank,支持按文件夹过滤"""
|
||||
collection = self.get_collection(user_id)
|
||||
|
||||
# 构建过滤条件
|
||||
where = None
|
||||
if folder_id:
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
if folder_path:
|
||||
where = {"folder_path": {"$starts_with": folder_path}}
|
||||
|
||||
try:
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k * 3,
|
||||
where=where,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
# ... 其余不变
|
||||
|
||||
async def _get_folder_path(self, folder_id: str) -> str | None:
|
||||
"""获取文件夹的完整路径"""
|
||||
result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
return None
|
||||
|
||||
path_parts = [folder.name]
|
||||
current_parent_id = folder.parent_id
|
||||
|
||||
while current_parent_id:
|
||||
parent_result = await self.db.execute(
|
||||
select(Folder).where(Folder.id == current_parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
break
|
||||
path_parts.insert(0, parent.name)
|
||||
current_parent_id = parent.parent_id
|
||||
|
||||
return "/" + "/".join(path_parts)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 数据库迁移
|
||||
|
||||
**Files:**
|
||||
- 数据库操作: 添加 folders 表,添加 documents.folder_id 列
|
||||
|
||||
- [ ] **Step 1: 执行 SQL 迁移**
|
||||
|
||||
```python
|
||||
# 迁移脚本
|
||||
import asyncio
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
async def migrate():
|
||||
async with engine.begin() as conn:
|
||||
# 创建 folders 表
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id VARCHAR(36),
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES folders(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 添加 documents.folder_id 列
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)"))
|
||||
except Exception:
|
||||
pass # 列已存在
|
||||
|
||||
asyncio.run(migrate())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 - 创建 Folder API
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/folder.ts`
|
||||
|
||||
- [ ] **Step 1: 创建文件夹 API 客户端**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/folder.ts
|
||||
import api from './index'
|
||||
|
||||
export interface FolderCreate {
|
||||
name: string
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface FolderUpdate {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FolderItem {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FolderTree {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
children: FolderTree[]
|
||||
}
|
||||
|
||||
export const folderApi = {
|
||||
// 获取文件夹树
|
||||
getTree() {
|
||||
return api.get<FolderTree[]>('/api/folders')
|
||||
},
|
||||
|
||||
// 创建文件夹
|
||||
create(data: FolderCreate) {
|
||||
return api.post('/api/folders', data)
|
||||
},
|
||||
|
||||
// 重命名文件夹
|
||||
rename(id: string, data: FolderUpdate) {
|
||||
return api.put(`/api/folders/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除文件夹
|
||||
delete(id: string) {
|
||||
return api.delete(`/api/folders/${id}`)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 前端 - 修改 Document API
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 添加 folder_id 到 Document 类型**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/document.ts
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
title: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
file_path: string
|
||||
summary?: string
|
||||
chunk_count: number
|
||||
is_indexed: boolean
|
||||
folder_id?: string | null // 新增
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 前端 - 创建 FolderTree 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/FolderTree.vue`
|
||||
|
||||
- [ ] **Step 1: 创建递归文件夹树组件**
|
||||
|
||||
```vue
|
||||
<!-- frontend/src/components/FolderTree.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { folderApi, type FolderTree } from '@/api/folder'
|
||||
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
folders: FolderTree[]
|
||||
selectedId?: string | null
|
||||
onSelect: (folder: FolderTree) => void
|
||||
onCreate: (parentId: string | null) => void
|
||||
onRename: (folder: FolderTree) => void
|
||||
onDelete: (folder: FolderTree) => void
|
||||
}>()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
||||
e.preventDefault()
|
||||
// 显示右键菜单
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="folder-item"
|
||||
>
|
||||
<div
|
||||
class="folder-row"
|
||||
:class="{ selected: folder.id === selectedId }"
|
||||
@click="props.onSelect(folder)"
|
||||
@contextmenu="handleContextMenu($event, folder)"
|
||||
>
|
||||
<!-- 展开/折叠箭头 -->
|
||||
<button
|
||||
v-if="folder.children?.length"
|
||||
class="expand-btn"
|
||||
@click.stop="toggleExpand(folder.id)"
|
||||
>
|
||||
<ChevronRight
|
||||
:size="12"
|
||||
:class="{ rotated: expandedIds.has(folder.id) }"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="expand-placeholder"></span>
|
||||
|
||||
<!-- 文件夹图标 -->
|
||||
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
|
||||
<Folder v-else :size="14" class="folder-icon" />
|
||||
|
||||
<!-- 文件夹名称 -->
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="folder-actions">
|
||||
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onRename(folder)" title="重命名">
|
||||
<Edit2 :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onDelete(folder)" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子文件夹(递归) -->
|
||||
<div
|
||||
v-if="folder.children?.length && expandedIds.has(folder.id)"
|
||||
class="folder-children"
|
||||
>
|
||||
<FolderTree
|
||||
:folders="folder.children"
|
||||
:selected-id="selectedId"
|
||||
:on-select="onSelect"
|
||||
:on-create="onCreate"
|
||||
:on-rename="onRename"
|
||||
:on-delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* sci-fi 风格 */
|
||||
.folder-tree {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: rgba(0, 245, 212, 0.04);
|
||||
}
|
||||
|
||||
.folder-row.selected {
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--accent-amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.folder-row:hover .folder-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-radius: 3px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-actions button:hover {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 前端 - 改造 KnowledgeView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑**
|
||||
|
||||
主要改动:
|
||||
- 导入 FolderTree 组件
|
||||
- 添加文件夹状态和加载逻辑
|
||||
- 修改上传逻辑(需先选择文件夹)
|
||||
- 添加新建/重命名/删除文件夹的弹窗
|
||||
|
||||
```vue
|
||||
<!-- KnowledgeView.vue 核心逻辑改动 -->
|
||||
<script setup lang="ts">
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 新增
|
||||
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
|
||||
import FolderTreeComponent from '@/components/FolderTree.vue'
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
// 状态
|
||||
const folders = ref<FolderTree[]>([])
|
||||
const selectedFolderId = ref<string | null>(null)
|
||||
|
||||
// 加载文件夹树
|
||||
async function loadFolders() {
|
||||
const res = await folderApi.getTree()
|
||||
folders.value = res.data
|
||||
}
|
||||
|
||||
// 选择文件夹
|
||||
function onSelectFolder(folder: FolderTree) {
|
||||
selectedFolderId.value = folder.id
|
||||
loadDocumentsByFolder(folder.id)
|
||||
}
|
||||
|
||||
// 加载指定文件夹的文档
|
||||
async function loadDocumentsByFolder(folderId: string) {
|
||||
const res = await documentApi.list(folderId)
|
||||
documents.value = res.data
|
||||
}
|
||||
|
||||
// 新建文件夹弹窗
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
|
||||
function openNewFolderDialog(parentId: string | null = null) {
|
||||
newFolderParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = true
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
await folderApi.create({
|
||||
name: newFolderName.value,
|
||||
parent_id: newFolderParentId.value
|
||||
})
|
||||
await loadFolders()
|
||||
showNewFolderDialog.value = false
|
||||
}
|
||||
|
||||
// 重命名/删除类似...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="knowledge-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<Database :size="20" />
|
||||
<h1>KNOWLEDGE BASE</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn" @click="openNewFolderDialog(null)">
|
||||
<FolderPlus :size="14" /> 新建文件夹
|
||||
</button>
|
||||
<button class="btn primary" @click="triggerUpload">
|
||||
<Upload :size="14" /> 上传文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主布局:侧边栏 + 内容 -->
|
||||
<div class="main-layout">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<aside class="folder-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<Folder :size="14" />
|
||||
<span>文件夹</span>
|
||||
<button class="add-btn" @click="openNewFolderDialog(null)">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="folder-list">
|
||||
<FolderTreeComponent
|
||||
:folders="folders"
|
||||
:selected-id="selectedFolderId"
|
||||
:on-select="onSelectFolder"
|
||||
:on-create="openNewFolderDialog"
|
||||
:on-rename="openRenameDialog"
|
||||
:on-delete="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<main class="content-area">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-panel">...</div>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<div class="upload-zone">...</div>
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<div class="docs-section">...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建文件夹弹窗 -->
|
||||
<div v-if="showNewFolderDialog" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h3>新建文件夹</h3>
|
||||
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
|
||||
<div class="dialog-actions">
|
||||
<button @click="showNewFolderDialog = false">取消</button>
|
||||
<button class="primary" @click="createFolder">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
---
|
||||
|
||||
## Task 12: 集成测试
|
||||
|
||||
- [ ] **Step 1: 测试文件夹 CRUD**
|
||||
- [ ] **Step 2: 测试级联删除**
|
||||
- [ ] **Step 3: 测试上传文档到指定文件夹**
|
||||
- [ ] **Step 4: 测试按文件夹搜索**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
共 12 个 Task,分 4 个 Phase:
|
||||
- **Phase 1**: 数据层 (Task 1-4)
|
||||
- **Phase 2**: 后端 API (Task 5-7)
|
||||
- **Phase 3**: 前端 (Task 8-11)
|
||||
- **Phase 4**: 测试 (Task 12)
|
||||
@@ -1,764 +0,0 @@
|
||||
# LLM Config Table UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 Settings 页面的 LLM 模型配置从卡片列表改为表格行内编辑形式
|
||||
|
||||
**Architecture:** 使用 Vue 3 Composition API,在 SettingsView.vue 内实现 4 个 LLM 类型区的表格组件,每种类型独立成区,支持行内展开编辑、测试连接、保存操作。
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Lucide Icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── SettingsView.vue # 重构:表格行内编辑 UI(所有逻辑内聚在此文件)
|
||||
└── components/settings/
|
||||
└── LLMTableRow.vue # 表格行组件(抽取以保持 SettingsView.vue 简洁)
|
||||
|
||||
backend/app/
|
||||
├── routers/settings.py # 确认测试 API 存在
|
||||
└── services/settings_service.py # 确认无需修改
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 验证后端测试 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/routers/settings.py`
|
||||
- Modify: `backend/app/services/settings_service.py`
|
||||
|
||||
- [ ] **Step 1: 确认 `/api/settings/llm/test` 端点存在**
|
||||
|
||||
检查 `backend/app/routers/settings.py` 中是否有 `POST /api/settings/llm/test` 路由。
|
||||
|
||||
- [ ] **Step 2: 确认 `test_llm_connection` 函数存在**
|
||||
|
||||
检查 `backend/app/services/settings_service.py` 中是否有 `test_llm_connection` 函数。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git log --oneline -1
|
||||
# 如果确认后端无需修改:
|
||||
echo "Backend API already supports the new UI"
|
||||
# 如果发现问题需要修复:
|
||||
# git add backend/app/routers/settings.py
|
||||
# git commit -m "fix(settings): ensure test LLM API exists"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建 LLMTableRow 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/settings/LLMTableRow.vue`
|
||||
- Modify: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 LLMTableRow.vue 组件**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
|
||||
import type { LLMModelConfig } from '@/api/settings'
|
||||
|
||||
const props = defineProps<{
|
||||
model: LLMModelConfig
|
||||
isExpanded: boolean
|
||||
isNew?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'update', data: LLMModelConfig): void
|
||||
(e: 'delete'): void
|
||||
(e: 'test', data: LLMModelConfig): void
|
||||
}>()
|
||||
|
||||
const showApiKey = ref(false)
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.model.api_key || !props.model.model) return 'empty'
|
||||
if (props.model.enabled) return 'available'
|
||||
return 'unavailable'
|
||||
})
|
||||
|
||||
const statusConfig = computed(() => ({
|
||||
available: { icon: '●', color: '#10b981', label: '可用' },
|
||||
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
|
||||
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
|
||||
}[status.value]))
|
||||
|
||||
function onProviderChange() {
|
||||
const defaults: Record<string, string> = {
|
||||
ollama: 'http://localhost:11434',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
claude: 'https://api.anthropic.com',
|
||||
deepseek: 'https://api.deepseek.com/v1'
|
||||
}
|
||||
if (!props.model.base_url || Object.values(defaults).includes(props.model.base_url)) {
|
||||
emit('update', { ...props.model, base_url: defaults[props.model.provider] || '' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
|
||||
<!-- 表格行(可点击展开) -->
|
||||
<div class="row-summary" @click="emit('toggle')">
|
||||
<div class="cell cell-toggle">
|
||||
<ChevronDown v-if="isExpanded" :size="14" />
|
||||
<ChevronRight v-else :size="14" />
|
||||
</div>
|
||||
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
|
||||
<div class="cell cell-provider">{{ model.provider }}</div>
|
||||
<div class="cell cell-model">{{ model.model || '-' }}</div>
|
||||
<div class="cell cell-status" :style="{ color: statusConfig.color }">
|
||||
{{ statusConfig.icon }} {{ statusConfig.label }}
|
||||
</div>
|
||||
<div class="cell cell-actions" @click.stop>
|
||||
<button class="icon-btn danger" @click="emit('delete')" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开的详情面板 -->
|
||||
<div v-if="isExpanded" class="expand-panel">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>// PROVIDER</label>
|
||||
<select v-model="model.provider" @change="onProviderChange">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>// MODEL</label>
|
||||
<input v-model="model.model" type="text" placeholder="gpt-4o" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>// BASE URL</label>
|
||||
<input v-model="model.base_url" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>// API KEY</label>
|
||||
<div class="input-with-toggle">
|
||||
<input
|
||||
v-model="model.api_key"
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<button @click="showApiKey = !showApiKey">
|
||||
<Eye v-if="!showApiKey" :size="14" />
|
||||
<EyeOff v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="test-btn" @click="emit('test', model)">
|
||||
<Play :size="12" /> 测试连接
|
||||
</button>
|
||||
<button
|
||||
class="save-btn"
|
||||
:disabled="status !== 'available'"
|
||||
@click="emit('update', model)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button class="cancel-btn" @click="emit('toggle')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-row {
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.row-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-summary:hover {
|
||||
background: rgba(0,245,212,0.05);
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cell-toggle { width: 30px; }
|
||||
.cell-name { flex: 1; min-width: 120px; }
|
||||
.cell-provider { width: 80px; }
|
||||
.cell-model { width: 120px; }
|
||||
.cell-status { width: 80px; }
|
||||
.cell-actions { width: 40px; text-align: right; }
|
||||
|
||||
.expand-panel {
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-toggle input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-with-toggle button {
|
||||
width: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.test-btn, .save-btn, .cancel-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,245,212,0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: rgba(0,245,212,0.1);
|
||||
border: 1px solid rgba(0,245,212,0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-mid);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 SettingsView.vue 中引入 LLMTableRow**
|
||||
|
||||
```typescript
|
||||
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/settings/LLMTableRow.vue
|
||||
git commit -m "feat(settings): create LLMTableRow component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 重构 SettingsView.vue 主体结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 清理原有 LLM 配置相关代码**
|
||||
|
||||
删除原有的 `llmConfig` 卡片列表 UI(template 中的 `.model-list`、`.model-item` 等部分),保留 profile 和 scheduler 配置部分。
|
||||
|
||||
- [ ] **Step 2: 添加 LLM 配置状态管理**
|
||||
|
||||
```typescript
|
||||
// LLM 配置
|
||||
const llmConfig = ref<LLMConfig>({
|
||||
chat: [],
|
||||
vlm: [],
|
||||
embedding: [],
|
||||
rerank: []
|
||||
})
|
||||
|
||||
// 原始配置(用于比较变更)
|
||||
const originalLlmConfig = ref<LLMConfig>({ chat: [], vlm: [], embedding: [], rerank: [] })
|
||||
|
||||
// 展开的行
|
||||
const expandedRow = ref<string | null>(null) // 'chat-0', 'vlm-0' 等
|
||||
|
||||
// 当前正在编辑的模型快照(用于取消时恢复)
|
||||
const editingSnapshot = ref<{ type: string; index: number; data: LLMModelConfig } | null>(null)
|
||||
|
||||
// 必填警告
|
||||
const showRequiredWarning = computed(() => {
|
||||
return llmConfig.value.chat.length === 0 ||
|
||||
llmConfig.value.embedding.length === 0 ||
|
||||
llmConfig.value.rerank.length === 0
|
||||
})
|
||||
|
||||
// 行标识
|
||||
function getRowKey(type: string, index: number): string {
|
||||
return `${type}-${index}`
|
||||
}
|
||||
|
||||
// 切换行展开
|
||||
function toggleRow(type: string, index: number, model: LLMModelConfig) {
|
||||
const key = getRowKey(type, index)
|
||||
if (expandedRow.value === key) {
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
} else {
|
||||
// 保存快照用于取消
|
||||
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) }
|
||||
expandedRow.value = key
|
||||
}
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (editingSnapshot.value && editingSnapshot.value.type === type && editingSnapshot.value.index === index) {
|
||||
// 恢复原始数据
|
||||
llmConfig.value[type as keyof LLMConfig]![index] = editingSnapshot.value.data
|
||||
}
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现添加模型**
|
||||
|
||||
```typescript
|
||||
function addModel(type: string) {
|
||||
if (!llmConfig.value[type as keyof LLMConfig]) {
|
||||
llmConfig.value[type as keyof LLMConfig] = []
|
||||
}
|
||||
// embedding/rerank 最多 1 个
|
||||
if ((type === 'embedding' || type === 'rerank') &&
|
||||
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
|
||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
|
||||
return
|
||||
}
|
||||
const newModel: LLMModelConfig = {
|
||||
name: `${type.toUpperCase()}-${Date.now()}`,
|
||||
provider: 'openai',
|
||||
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
enabled: false
|
||||
}
|
||||
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
|
||||
// 自动展开新添加的行
|
||||
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
|
||||
expandedRow.value = getRowKey(type, newIndex)
|
||||
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现删除模型**
|
||||
|
||||
```typescript
|
||||
function removeModel(type: string, index: number) {
|
||||
// embedding/rerank 为知识库必填,至少保留 1 个
|
||||
if ((type === 'embedding' || type === 'rerank') &&
|
||||
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
|
||||
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
|
||||
return
|
||||
}
|
||||
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现更新模型**
|
||||
|
||||
```typescript
|
||||
function updateModel(type: string, index: number, model: LLMModelConfig) {
|
||||
llmConfig.value[type as keyof LLMConfig]![index] = model
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现测试连接**
|
||||
|
||||
```typescript
|
||||
async function testModel(type: string, index: number, model: LLMModelConfig) {
|
||||
try {
|
||||
const res = await settingsApi.testLLM({ type: type as any, ...model })
|
||||
if (res.data.success) {
|
||||
// 测试通过,标记为可用
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
|
||||
showToast('连接成功')
|
||||
} else {
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||
showToast(`连接失败: ${res.data.error}`, 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
|
||||
showToast('测试连接失败', 'error')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 实现保存模型**
|
||||
|
||||
```typescript
|
||||
async function saveModel(type: string, index: number) {
|
||||
const key = getRowKey(type, index)
|
||||
savingModel.value = key
|
||||
try {
|
||||
await settingsApi.updateLLM(llmConfig.value)
|
||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||
expandedRow.value = null
|
||||
editingSnapshot.value = null
|
||||
showToast('保存成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
|
||||
showToast(msg, 'error')
|
||||
} finally {
|
||||
savingModel.value = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 编写 template 的 LLM Config Section**
|
||||
|
||||
在 Profile section 之后,Scheduler section 之前添加:
|
||||
|
||||
```html
|
||||
<!-- LLM Config Section -->
|
||||
<div class="settings-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">// LLM CONFIGURATION</span>
|
||||
</div>
|
||||
|
||||
<!-- 必填警告 -->
|
||||
<div v-if="showRequiredWarning" class="warning-bar">
|
||||
⚠ chat / embedding / rerank 为知识库必填,请确保已配置
|
||||
</div>
|
||||
|
||||
<!-- Chat Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">CHAT</span>
|
||||
<button class="add-btn" @click="addModel('chat')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.chat" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('chat', index)"
|
||||
@toggle="toggleRow('chat', index, model)"
|
||||
@update="(m) => updateModel('chat', index, m)"
|
||||
@delete="removeModel('chat', index)"
|
||||
@test="(m) => testModel('chat', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 chat 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- VLM Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
|
||||
<button class="add-btn" @click="addModel('vlm')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.vlm" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('vlm', index)"
|
||||
@toggle="toggleRow('vlm', index, model)"
|
||||
@update="(m) => updateModel('vlm', index, m)"
|
||||
@delete="removeModel('vlm', index)"
|
||||
@test="(m) => testModel('vlm', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 vlm 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
|
||||
<button class="add-btn" @click="addModel('embedding')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.embedding" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('embedding', index)"
|
||||
@toggle="toggleRow('embedding', index, model)"
|
||||
@update="(m) => updateModel('embedding', index, m)"
|
||||
@delete="removeModel('embedding', index)"
|
||||
@test="(m) => testModel('embedding', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 embedding 模型配置</div>
|
||||
</div>
|
||||
|
||||
<!-- Rerank Section -->
|
||||
<div class="llm-type-section">
|
||||
<div class="llm-type-header">
|
||||
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
|
||||
<button class="add-btn" @click="addModel('rerank')">
|
||||
<Plus :size="12" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
|
||||
<div v-for="(model, index) in llmConfig.rerank" :key="index">
|
||||
<LLMTableRow
|
||||
:model="model"
|
||||
:is-expanded="expandedRow === getRowKey('rerank', index)"
|
||||
@toggle="toggleRow('rerank', index, model)"
|
||||
@update="(m) => updateModel('rerank', index, m)"
|
||||
@delete="removeModel('rerank', index)"
|
||||
@test="(m) => testModel('rerank', index, m)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无 rerank 模型配置</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 添加相关样式**
|
||||
|
||||
```css
|
||||
/* LLM Type Section */
|
||||
.llm-type-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.llm-type-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.llm-type-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.optional-tag {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.required-tag {
|
||||
font-size: 9px;
|
||||
color: var(--accent-red);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Warning Bar */
|
||||
.warning-bar {
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Model Table */
|
||||
.model-table {
|
||||
/* 表格容器 */
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
border: 1px dashed var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 更新 loadSettings 函数**
|
||||
|
||||
确保 LLM 配置正确加载:
|
||||
|
||||
```typescript
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await settingsApi.get()
|
||||
profile.value = {
|
||||
email: res.data.profile.email,
|
||||
full_name: res.data.profile.full_name || '',
|
||||
created_at: res.data.profile.created_at
|
||||
}
|
||||
originalProfile.value = { ...profile.value }
|
||||
|
||||
// 加载 LLM 配置
|
||||
if (res.data.llm_config) {
|
||||
llmConfig.value = {
|
||||
chat: res.data.llm_config.chat || [],
|
||||
vlm: res.data.llm_config.vlm || [],
|
||||
embedding: res.data.llm_config.embedding || [],
|
||||
rerank: res.data.llm_config.rerank || []
|
||||
}
|
||||
} else {
|
||||
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
|
||||
}
|
||||
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
|
||||
|
||||
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
|
||||
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
|
||||
}
|
||||
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
|
||||
} catch (e) {
|
||||
console.error('加载设置失败', e)
|
||||
showToast('加载设置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: 注册组件**
|
||||
|
||||
```typescript
|
||||
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
|
||||
// 在 components 中注册
|
||||
components: { LLMTableRow }
|
||||
```
|
||||
|
||||
- [ ] **Step 12: 提交**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/SettingsView.vue
|
||||
git commit -m "feat(settings): refactor LLM config to table inline-edit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 测试和验证
|
||||
|
||||
**Files:**
|
||||
- Test: `frontend/src/views/SettingsView.vue`
|
||||
|
||||
- [ ] **Step 1: 手动测试流程**
|
||||
|
||||
1. 打开 Settings 页面
|
||||
2. 确认 chat/embedding/rerank 必填警告(如果为空)
|
||||
3. 添加新模型,点击 [+] 按钮
|
||||
4. 填写模型信息,点击"测试连接"
|
||||
5. 测试通过后,"保存"按钮可用
|
||||
6. 保存成功,刷新页面确认数据持久化
|
||||
7. 点击"取消"验证表单数据恢复
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(settings): complete LLM config table UI implementation"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
# 2026-04-03 L3 Runtime Hardening Plan
|
||||
|
||||
## Goal
|
||||
先把 Jarvis 的 L3 主链夯实,只处理 runtime / graph / tools / service integration / tests / docs 的一致性问题;暂不继续扩 unrelated feature domain。
|
||||
|
||||
## Scope
|
||||
- `backend/app/agents/graph.py`
|
||||
- `backend/app/agents/state.py`
|
||||
- `backend/app/agents/tools/__init__.py`
|
||||
- `backend/app/agents/tools/search.py`
|
||||
- `backend/app/agents/tools/schedule.py`
|
||||
- `backend/app/agents/tools/task.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
- `backend/app/services/document_service.py`
|
||||
- `backend/app/services/memory_service.py`
|
||||
- `backend/tests/backend/app/agents/test_graph*.py`
|
||||
- `backend/tests/backend/app/services/test_brain_ingestion.py`
|
||||
- related design/plan docs under `docs/superpowers/`
|
||||
|
||||
## Non-goals
|
||||
- 不在本轮新增前端页面
|
||||
- 不在 L3 未稳定前继续扩 accounting / weather / RSS 等运行时域
|
||||
- 不重做 graph 架构,只做收敛、对齐和补测试
|
||||
|
||||
## Current High-Priority Gaps
|
||||
1. **continuity / clarification schema drift**
|
||||
- graph runtime 已使用 `owning_agent` / `owning_sub_commander` / `target_action`
|
||||
- brain ingestion tests 仍大量使用旧快照字段:`active_sub_flow` / `awaiting_user_input` 等
|
||||
2. **tool execution drift**
|
||||
- `search.py` 的 `_run_async()` 在 running loop 下实现不一致
|
||||
- schedule/task canonicalization 仍存在参数映射漂移
|
||||
3. **service integration drift**
|
||||
- `agent_service` 已派生 role-scoped memory sections,但 continuity snapshot / graph runtime / persisted attachments 需要继续收口
|
||||
4. **docs drift**
|
||||
- 现有文档已记录 L3 merge progress,但缺少一份当天可执行的 hardening 追踪文档
|
||||
|
||||
## Workstreams
|
||||
|
||||
### Workstream A — Continuity Contract
|
||||
Owner: worker-1
|
||||
|
||||
Target:
|
||||
- 对齐 clarification / continuity canonical schema
|
||||
- 让 graph runtime 与 persisted snapshot 使用同一套契约,或显式兼容旧字段
|
||||
- 补针对性测试
|
||||
|
||||
Done when:
|
||||
- graph 与 ingestion tests 对 clarification/continuity 断言一致
|
||||
- stale continuity / resume-after-clarification 场景有回归覆盖
|
||||
- 文档明确列出 canonical 字段和兼容规则
|
||||
|
||||
### Workstream B — Tool Execution Path
|
||||
Owner: worker-2
|
||||
|
||||
Target:
|
||||
- 修复 search async bridge
|
||||
- 对齐 task / schedule canonicalization
|
||||
- 固定当前 L3 scope 下真实支持的 tool/fallback 规则
|
||||
|
||||
Current status:
|
||||
- 已统一 `search.py` / `schedule.py` / `task.py` 到共享 `app.agents.tools.async_bridge.run_async`,避免 running loop 下的同步桥接漂移。
|
||||
- 已收敛 graph canonicalization:`create_todo` 保留 date/todo_date 语义;仅在出现 timed task 信号时提升为 `create_schedule_task`;`create_goal` 统一落到 `goal_date`;`create_reminder` clarification 前会先标准化 `date`。
|
||||
- 已补 targeted regressions,覆盖 active event loop search path、timed todo promotion、reminder clarification date normalization。
|
||||
|
||||
Done when:
|
||||
- 相关工具测试通过
|
||||
- graph canonicalization 行为清晰且无死分支
|
||||
- 文档明确说明支持的 tool path 与 deferred domains
|
||||
|
||||
### Workstream C — Service Integration
|
||||
Owner: worker-3
|
||||
|
||||
Target:
|
||||
- 对齐 graph runtime 与 `agent_service` 入口语义
|
||||
- 收敛 continuity snapshot、role-scoped context、stream/sync 行为
|
||||
- 补接入层测试或针对性断言
|
||||
|
||||
Done when:
|
||||
- `agent_service` 与 graph 状态注入规则一致
|
||||
- continuity snapshot load/persist 行为有测试证据
|
||||
- 文档明确 graph/service 边界和责任
|
||||
|
||||
## Runtime Contract Notes
|
||||
### Clarification context
|
||||
Canonical target shape:
|
||||
- `owning_agent`
|
||||
- `owning_sub_commander`
|
||||
- `target_action`
|
||||
- `question`
|
||||
- `partial_args`
|
||||
- `missing_fields`
|
||||
- `status`
|
||||
|
||||
### Continuity state
|
||||
Current known active markers:
|
||||
- `status: fresh|stale`
|
||||
- `mode: resume_after_clarification` for clarification continuation
|
||||
- routing continuation should only survive when the new request is still semantically a continuation
|
||||
|
||||
### Tool strategy
|
||||
Current target contract:
|
||||
- native tools and JSON fallback should converge on the same normalized tool name + normalized args before execution
|
||||
- system messages should remain coalesced into one system message for OpenAI-compatible providers that reject multiple system messages
|
||||
- sync tool shims in current L3 scope must route through shared `async_bridge.run_async` instead of per-file event-loop wrappers
|
||||
|
||||
### Current L3 tool path rules
|
||||
- `librarian_retrieval` current allowlist: `search_knowledge`, `hybrid_search`, `web_search`, `get_knowledge_graph_context`
|
||||
- search-family sync wrappers must be safe under an already-running event loop
|
||||
- `create_todo` keeps day-level intent on `todo_date`; do not silently remap date-only todo requests to task due dates
|
||||
- `create_todo` upgrades to `create_schedule_task` only for timed/task-shaped payloads such as `due_time`, `due_datetime`, `start_time`, `end_time`
|
||||
- `create_goal` date aliases normalize to `goal_date`
|
||||
- `create_reminder` aliases normalize before clarification so resumed flows keep canonical partial args
|
||||
|
||||
### Explicitly deferred domains in this hardening pass
|
||||
- accounting runtime expansion
|
||||
- weather runtime expansion
|
||||
- RSS runtime expansion
|
||||
- any new tool domains outside current schedule / task / forum / knowledge L3 path
|
||||
|
||||
## Documentation Rule For This Hardening Pass
|
||||
每完成一个 workstream:
|
||||
1. 更新本文件的 status
|
||||
2. 在相关 spec/notes 中补一段“当前状态 / 已决策 / 已知边界”
|
||||
3. 再标记任务完成
|
||||
|
||||
## Status
|
||||
- [x] Hardening tracker created
|
||||
- [x] Workstream A complete
|
||||
- [x] Workstream B complete
|
||||
- [x] Workstream C complete
|
||||
- [x] Final verification pass complete
|
||||
|
||||
## Verification Checklist
|
||||
- [x] `test_graph_system_messages.py` → 8 passed
|
||||
- [x] `test_tool_async_bridge.py` + `test_task_tools.py` → 18 passed
|
||||
- [x] `test_brain_ingestion.py` full file → 40 passed
|
||||
- [x] targeted continuity persistence/rehydration checks → 3 passed
|
||||
- [x] targeted graph regressions for timed todo / reminder clarification / active event loop paths
|
||||
- [ ] broader graph suite beyond this L3 slice
|
||||
|
||||
## Final Notes
|
||||
- L3 continuity persistence now uses one canonical envelope and normalizes legacy snapshot shapes on rehydration.
|
||||
- Service/runtime integration is aligned on the canonical continuity schema rather than legacy raw snapshot persistence.
|
||||
- Tool sync shims now share one async bridge across search / schedule / task / forum paths.
|
||||
- Final verification was executed with `uv run pytest` from `backend/`, which bypassed the broken plain `python` launcher in this environment.
|
||||
- A reviewer flagged async bridge timeout/cancellation semantics as a follow-up reliability concern for mutating tools, but it is not blocking this L3 hardening pass.
|
||||
|
||||
## Next Action
|
||||
- Treat this L3 hardening slice as complete.
|
||||
- If continuing, the next best follow-up is either broader graph regression coverage or a dedicated fix for async bridge timeout/cancellation semantics.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Agent Dashboard 页面设计规格
|
||||
|
||||
## 概述
|
||||
|
||||
为 Jarvis 系统设计一个 Agent 管理页面,以全息战术投影(Holographic Tactical HUD)风格可视化展示 Master + 4 Sub-Agent 的组织架构,支持查看状态和配置。
|
||||
|
||||
## 视觉风格
|
||||
|
||||
- **主题**:全息战术投影(科幻指挥台)
|
||||
- **背景**:#03050a 深空黑 + 微弱网格线 + 全息扫描线纹理
|
||||
- **节点样式**:半透明玻璃态卡片,悬浮空中,全息光晕边框
|
||||
- **字体**:Orbitron(标题)+ JetBrains Mono(正文)
|
||||
- **配色**:Cyan #00f5d4 主色,Amber #f9a825 强调色,Red #ff4757 危险色
|
||||
|
||||
## 布局结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ AGENT COMMAND CENTER [刷新] [新增] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ MASTER CORE │ │
|
||||
│ │ JARVIS 指挥官 │ │
|
||||
│ │ [●] 状态灯 │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ PLANNER │ │ EXECUTOR │ │LIBRARIAN │ │
|
||||
│ │ [●] │ │ [●] │ │ [●] │ │
|
||||
│ │ 规划者 │ │ 执行者 │ │ 知识官 │ │
|
||||
│ │ 调用:12 │ │ 调用:8 │ │ 调用:5 │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ ANALYST │ │
|
||||
│ │ [●] │ │
|
||||
│ │ 分析师 │ │
|
||||
│ │ 调用:3 │ │
|
||||
│ └───────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
点击节点 → 右侧滑出配置抽屉
|
||||
```
|
||||
|
||||
## 节点卡片字段
|
||||
|
||||
- 名称(Orbitron)
|
||||
- 角色标签(中文)
|
||||
- 状态灯:绿色脉冲=活跃,灰色=空闲
|
||||
- 角色描述(2行)
|
||||
- 调用次数(今日)
|
||||
- 当前任务摘要
|
||||
|
||||
## 连接线
|
||||
|
||||
- 虚线连接 Master → Sub-Agent
|
||||
- 任务触发时:琥珀色脉冲光点沿路径流向目标节点
|
||||
|
||||
## 配置面板(右侧抽屉 400px)
|
||||
|
||||
- Agent 名称
|
||||
- 角色描述
|
||||
- 系统提示词(textarea)
|
||||
- 启用/停用开关
|
||||
- 保存 / 重置按钮
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 固定结构:前端 `src/data/agents.ts`
|
||||
- 运行时状态:`/api/agents/stats`
|
||||
|
||||
## API 设计
|
||||
|
||||
```
|
||||
GET /api/agents/stats → { agent_id, call_count, current_task, status }
|
||||
GET /api/agents/config/{id} → 返回单个 Agent 完整配置
|
||||
PUT /api/agents/config/{id} → 更新 name/description/system_prompt/enabled
|
||||
```
|
||||
@@ -1,192 +0,0 @@
|
||||
# 沟通系统增强设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
在沟通系统(ChatView)中增加两个功能:
|
||||
1. **文件上传** - 用户可在对话中上传文件,AI 自动理解内容并回复
|
||||
2. **表情包选择器** - 在发送按钮旁添加 emoji 选择面板
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 文件上传
|
||||
|
||||
**前端实现:**
|
||||
- 在 `ChatView.vue` 输入区域添加附件按钮(Paperclip 图标)
|
||||
- 使用 `<input type="file">` 触发文件选择
|
||||
- 支持类型:图片(jpg/png/gif/webp)、文档(pdf/doc/docx/xls/xlsx/ppt/pptx/txt)
|
||||
- 文件大小限制:10MB
|
||||
- 上传时显示进度状态
|
||||
|
||||
**消息气泡展示:**
|
||||
- 文件上传成功后,在对话中显示文件消息气泡
|
||||
- 气泡内容:文件图标 + 文件名 + 文件大小
|
||||
- 点击可下载/预览
|
||||
|
||||
**后端实现:**
|
||||
- 复用现有 `/api/documents/upload` 接口上传文件
|
||||
- 创建 KGNode(entity_type: 'document')关联到对话
|
||||
- 修改 `AgentService.chat_simple()` 支持文件上下文
|
||||
- AI 自动读取上传文件内容并理解
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
用户选择文件 → 前端上传到 /api/documents/upload
|
||||
→ 后端存储文件,创建 KGNode
|
||||
→ 前端发送消息带 file_ids
|
||||
→ AgentService 读取文件内容
|
||||
→ AI 基于文件内容回复
|
||||
```
|
||||
|
||||
### 2.2 表情包选择器
|
||||
|
||||
**前端实现:**
|
||||
- 在发送按钮旁添加 Emoji 图标按钮
|
||||
- 点击展开浮层面板,显示 emoji 分类网格
|
||||
- 分类:😀 笑脸 | 👍 手势 | 📦 物品 | 💬 符号
|
||||
- 每个分类显示常用 emoji 网格
|
||||
- 点击 emoji 插入到输入框
|
||||
- 点击外部关闭面板
|
||||
|
||||
**Emoji 数据:**
|
||||
```typescript
|
||||
const emojiCategories = {
|
||||
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
|
||||
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
|
||||
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
|
||||
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡']
|
||||
}
|
||||
```
|
||||
|
||||
## 3. API 变更
|
||||
|
||||
### 3.1 修改 ChatRequest
|
||||
|
||||
```python
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
file_ids: list[str] = [] # 新增:上传的文件ID列表
|
||||
```
|
||||
|
||||
### 3.2 修改 Message 模型(可选扩展)
|
||||
|
||||
```python
|
||||
class Message(BaseModel):
|
||||
# 新增字段
|
||||
attachments: list[dict] = [] # [{file_id, filename, file_type, file_size}]
|
||||
```
|
||||
|
||||
### 3.3 新增文件读取接口
|
||||
|
||||
```
|
||||
GET /api/documents/{document_id}/content
|
||||
返回: 文件的文本内容(用于 AI 理解)
|
||||
```
|
||||
|
||||
## 4. 组件变更
|
||||
|
||||
### 4.1 ChatView.vue 变更
|
||||
|
||||
**新增:**
|
||||
- `fileInput` ref - 文件 input
|
||||
- `showEmojiPicker` ref - emoji 面板显示状态
|
||||
- `selectedFiles` ref - 已选择待上传文件
|
||||
- `uploadFile()` - 上传文件方法
|
||||
- `insertEmoji()` - 插入 emoji 到输入框
|
||||
|
||||
**修改:**
|
||||
- 输入区域布局:附件按钮 | 输入框 | Emoji按钮 | 发送按钮
|
||||
- `sendMessage()` - 发送前先上传文件,获取 file_ids
|
||||
|
||||
### 4.2 EmojiPicker 组件(新建)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = {
|
||||
smile: { name: '😀', emojis: [...] },
|
||||
gesture: { name: '👍', emojis: [...] },
|
||||
object: { name: '📦', emojis: [...] },
|
||||
symbol: { name: '💬', emojis: [...] }
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4.3 FileMessage 组件(新建)
|
||||
|
||||
用于展示文件消息气泡:
|
||||
- 文件图标(根据类型)
|
||||
- 文件名(可截断)
|
||||
- 文件大小
|
||||
- 下载按钮
|
||||
|
||||
## 5. 错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 文件类型不支持 | 提示"不支持该文件类型" |
|
||||
| 文件超过10MB | 提示"文件超过10MB限制" |
|
||||
| 上传失败 | 提示"上传失败,请重试",显示重试按钮 |
|
||||
| AI读取文件失败 | AI 回复"无法读取文件内容" |
|
||||
| 网络断开 | 提示"网络连接断开" |
|
||||
|
||||
## 6. 状态定义
|
||||
|
||||
| 状态 | 显示 |
|
||||
|------|------|
|
||||
| 上传中 | 进度环 + 文件名 |
|
||||
| 上传成功 | 文件气泡 |
|
||||
| 上传失败 | 错误图标 + 重试按钮 |
|
||||
| AI 读取中 | AI 思考状态..." |
|
||||
|
||||
## 7. 实现顺序
|
||||
|
||||
1. **Phase 1: 基础 UI**
|
||||
- 添加附件按钮和 Emoji 按钮到输入区域
|
||||
- Emoji 选择器组件
|
||||
- 文件消息气泡组件
|
||||
|
||||
2. **Phase 2: 文件上传**
|
||||
- 前端文件上传逻辑
|
||||
- 消息带 file_ids
|
||||
- 文件气泡展示
|
||||
|
||||
3. **Phase 3: AI 理解文件**
|
||||
- 后端文件内容读取接口
|
||||
- AgentService 支持文件上下文
|
||||
- 测试完整流程
|
||||
|
||||
## 8. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ └── ChatView.vue # 修改 - 添加附件/Emoji按钮
|
||||
├── components/
|
||||
│ ├── chat/
|
||||
│ │ ├── EmojiPicker.vue # 新建 - Emoji 选择器
|
||||
│ │ └── FileMessage.vue # 新建 - 文件消息气泡
|
||||
│ └── stats/ # 已存在
|
||||
│ └── ...
|
||||
└── api/
|
||||
├── conversation.ts # 修改 - chat 支持 file_ids
|
||||
└── document.ts # 新增 - getDocumentContent
|
||||
|
||||
backend/app/
|
||||
├── routers/
|
||||
│ ├── conversation.py # 修改 - ChatRequest 支持 file_ids
|
||||
│ └── document.py # 修改 - 新增 content 接口
|
||||
├── services/
|
||||
│ └── agent_service.py # 修改 - chat 支持文件上下文
|
||||
└── models/
|
||||
└── conversation.py # 修改 - Message 新增 attachments
|
||||
```
|
||||
@@ -1,178 +0,0 @@
|
||||
# Daily Todo 功能设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
每日待办(Daily Todo)是一个以"天"为维度的任务管理模块,与现有的看板(以项目/多天为维度)形成互补。
|
||||
|
||||
**核心价值:** AI 每天早上自动预生成今日待办(基于前一天未完成的看板任务 + 前一天对话记录),用户可手动增删改。
|
||||
|
||||
## 时区说明
|
||||
|
||||
- 所有日期相关字段均使用**用户本地日期**(后端统一用 `datetime.date.today()` 计算,不依赖 UTC)
|
||||
- `todo_date` 格式:`YYYY-MM-DD`(本地日期字符串),便于按天查询
|
||||
|
||||
## 数据模型
|
||||
|
||||
### DailyTodo 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | String(36) | 主键,UUID |
|
||||
| user_id | String(36) | 所属用户,索引 |
|
||||
| title | String(500) | 待办标题 |
|
||||
| is_completed | Boolean | 是否完成,默认 false |
|
||||
| source | Enum | `ai_kanban` / `ai_chat` / `manual`,来源 |
|
||||
| source_detail | String(500) | 展示用说明文本,如"看板:完成用户登录功能" |
|
||||
| source_ref_id | String(36) | 来源原始ID(看板TaskID或对话ConversationID),可空 |
|
||||
| todo_date | String(10) | 所属日期,格式 YYYY-MM-DD,复合索引 (user_id, todo_date) |
|
||||
| completed_at | DateTime | 完成时间,可空 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| updated_at | DateTime | 更新时间 |
|
||||
|
||||
**索引:** `INDEX (user_id, todo_date)`,查询今日待办的主要路径
|
||||
|
||||
### DailyTodoHistory 归档表
|
||||
|
||||
归档时机:每天凌晨 1:00,APScheduler 清理 7 天前的记录
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | String(36) | 主键,UUID |
|
||||
| original_id | String(36) | 原记录ID(原记录归档后可能已删除) |
|
||||
| user_id | String(36) | 所属用户 |
|
||||
| title | String(500) | 待办标题 |
|
||||
| is_completed | Boolean | 最终完成状态 |
|
||||
| source | Enum | 来源 |
|
||||
| source_detail | String(500) | 展示用说明文本 |
|
||||
| todo_date | String(10) | 所属日期 |
|
||||
| completed_at | DateTime | 完成时间 |
|
||||
| created_at | DateTime | 创建时间 |
|
||||
| archived_at | DateTime | 归档时间 |
|
||||
|
||||
**保留策略:** 归档记录保留 7 天,到期自动删除(APScheduler 每日清理)
|
||||
|
||||
## 核心功能
|
||||
|
||||
### F1: 今日待办列表
|
||||
- 展示当天的所有待办事项
|
||||
- 每条可勾选完成状态(勾选后划线 + 变灰)
|
||||
- 支持新增、编辑、删除
|
||||
- 按创建时间倒序排列
|
||||
- 分页:每页 50 条,支持 `page` + `page_size` 参数
|
||||
|
||||
### F2: 历史记录
|
||||
- 可查看昨天、前天等历史日期的待办
|
||||
- 切换日期查看,**只读**(历史不允许修改/删除)
|
||||
- 历史数据来自 `DailyTodo` 表(按 todo_date 过滤)
|
||||
- 注:不从 `DailyTodoHistory` 表读取——归档表仅作备份保留
|
||||
|
||||
### F3: AI 自动预生成
|
||||
- 触发时机:每天早上 8:00(APScheduler 定时任务),也可手动触发
|
||||
- 数据来源:
|
||||
1. **看板任务**:前一天创建的、状态 ≠ done 的任务,取前 20 条(按 created_at 倒序)
|
||||
2. **对话记录**:前一天创建的对话,取其消息内容前 2000 字发给 LLM
|
||||
- AI 处理流程:
|
||||
1. 查询上述数据,拼装为分析文本
|
||||
2. 发送给 LLM,Prompt 要求输出 JSON 数组:`[{ "title": "...", "reason": "..." }]`
|
||||
3. 解析 LLM 返回,若返回为空或解析失败则跳过对话分析
|
||||
4. 批量写入 DailyTodo 表(source=ai_kanban / ai_chat)
|
||||
- **幂等处理(关键)**:使用事务 + 插入前检查,确保同一天不会重复生成
|
||||
```
|
||||
BEGIN TRANSACTION
|
||||
IF EXISTS (SELECT 1 FROM daily_todos WHERE user_id=? AND todo_date=? AND source IN ('ai_kanban','ai_chat')):
|
||||
ROLLBACK -- 已有AI生成,跳过
|
||||
ELSE:
|
||||
INSERT ... -- 批量写入
|
||||
COMMIT
|
||||
```
|
||||
- **容错**:LLM 不可用时记录日志,跳过该部分,不阻塞整体流程
|
||||
- 看板任务上限 20 条,对话分析最多提取 3 条
|
||||
|
||||
### F4: AI 来源说明
|
||||
- 每条 AI 生成的待办,显示其来源说明
|
||||
- `source=ai_kanban`:`source_detail` = "看板:{任务标题}",`source_ref_id` = 原始 Task ID
|
||||
- `source=ai_chat`:`source_detail` = "对话:{reason 摘要(截取前60字)}"
|
||||
|
||||
## API 设计
|
||||
|
||||
### GET /api/todos
|
||||
查询待办列表(支持分页)
|
||||
- Query: `?date=2026-03-20&page=1&page_size=50`(date 默认当天)
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"items": [DailyTodoOut],
|
||||
"total": 12,
|
||||
"page": 1,
|
||||
"page_size": 50
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/todos
|
||||
新增待办(手动)
|
||||
- Body: `{ title: string }`
|
||||
- source 固定为 `manual`,todo_date 为当天
|
||||
|
||||
### PATCH /api/todos/{id}
|
||||
更新待办(完成状态 / 标题)
|
||||
- Body: `{ is_completed?: boolean, title?: string }`
|
||||
- 仅当日待办可修改,历史日期返回 403
|
||||
|
||||
### DELETE /api/todos/{id}
|
||||
删除待办
|
||||
- 仅当日待办可删除,历史日期返回 403
|
||||
|
||||
### POST /api/todos/ai-generate
|
||||
手动触发 AI 预生成
|
||||
- 检查今日是否已有 AI 生成记录,有则返回 200(幂等,不重复生成)
|
||||
- 无则执行 AI 分析流程,返回生成结果
|
||||
|
||||
### GET /api/todos/summary
|
||||
获取今日待办摘要
|
||||
- Response: `{ date: "2026-03-20", total: 5, completed: 2, pending: 3 }`
|
||||
|
||||
## 响应 Schema
|
||||
|
||||
### DailyTodoOut
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "完成用户登录功能",
|
||||
"is_completed": false,
|
||||
"source": "ai_kanban",
|
||||
"source_detail": "看板:完成用户登录功能",
|
||||
"todo_date": "2026-03-20",
|
||||
"completed_at": null,
|
||||
"created_at": "2026-03-20T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 定时任务
|
||||
|
||||
| 任务 | 时间 | 说明 |
|
||||
|------|------|------|
|
||||
| AI预生成 | 每天 08:00 | 为所有活跃用户执行 AI 预生成 |
|
||||
| 历史归档清理 | 每天 01:00 | 删除 7 天前已归档的 DailyTodo 记录 |
|
||||
|
||||
## 前端页面
|
||||
|
||||
### TodoView.vue
|
||||
- 路径:`/todo`
|
||||
- 布局:顶部日期导航 + 下方待办列表
|
||||
- 日期导航:今天、昨天、前天快捷按钮 + 日期选择器
|
||||
- 今日视图:输入框新增 + 列表 + "AI 规划今日"按钮
|
||||
- 历史视图:只读列表,无新增/删除按钮,灰色禁用样式
|
||||
- 交互细节:
|
||||
- 勾选完成:Motion 动画划线效果
|
||||
- 加载状态:骨架屏
|
||||
- 空状态:终端风格空提示
|
||||
- 风格:sci-fi 全息终端,cyan (#00f5d4) + #03050a,与 AgentView 一致
|
||||
|
||||
### 侧边栏
|
||||
- 新增菜单项:`{ name: '待办', path: '/todo', icon: CheckSquare }`
|
||||
|
||||
## 技术依赖
|
||||
|
||||
- 后端:FastAPI + SQLAlchemy + APScheduler + LLM Service
|
||||
- 前端:Vue 3 Composition API + 复用 api/index 的 axios 实例
|
||||
- 数据库:新表 DailyTodo + DailyTodoHistory(迁移 Alembic 或手动 CREATE TABLE)
|
||||
@@ -1,602 +0,0 @@
|
||||
# Jarvis 个人 AI 助理 — 设计规格书
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-03-20
|
||||
> 作者:Jarvis 设计团队
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 项目目标
|
||||
|
||||
构建一个拟人化的个人 AI 助理系统,代号 **Jarvis**。核心目标是打造一个真正"懂你"的智能体 —— 理解你的知识体系、工作安排和个人偏好,而不仅仅是关键词匹配回答问题。
|
||||
|
||||
### 1.2 核心价值
|
||||
|
||||
- **知识回溯能力** — 基于 LlamaIndex Node 关系 + 知识图谱双层架构,确保 AI 真正理解你的知识和工作的内在联系
|
||||
- **拟人化协作** — 多 Agent 角色协同,每个角色有独立职责,像真实团队成员一样交流
|
||||
- **全端覆盖** — Web + Android 双端,随时随地与 Jarvis 对话
|
||||
- **本地部署** — 所有数据存储在 NAS,数据完全自主可控
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|---------|------|
|
||||
| **Web 前端** | Vue 3 + TypeScript | Composition API,响应式 UI |
|
||||
| **移动端** | Kotlin (Android) | Jetpack Compose,轻量连接器 |
|
||||
| **后端框架** | FastAPI (Python 3.12+) | 高性能 ASGI,支持 async |
|
||||
| **Agent 框架** | LangGraph | 多 Agent 编排、工具调用、状态机流转 |
|
||||
| **LLM 适配器** | LangChain Claude / OpenAI / Ollama | 可切换,不影响上层逻辑 |
|
||||
| **知识库框架** | LlamaIndex | Node 关系索引、语义检索 |
|
||||
| **向量数据库** | ChromaDB | 轻量级向量存储 |
|
||||
| **关系数据库** | SQLite | 轻量数据持久化 |
|
||||
| **定时任务** | APScheduler | 定时任务调度 |
|
||||
| **部署环境** | NAS (本地) | Docker 容器化部署 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 系统架构
|
||||
|
||||
### 3.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 用户端 │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Web 前端 │ │ Android App │ │
|
||||
│ │ (Vue 3 + TS) │ │ (Kotlin) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
└───────────┼────────────────────────┼─────────────────┘
|
||||
│ │
|
||||
│ HTTP / WebSocket │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────────────────┐
|
||||
│ FastAPI 后端服务 │
|
||||
│ (NAS Docker 容器) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 多 Agent 调度系统 │ │
|
||||
│ │ ┌─────────┐ │ │
|
||||
│ │ │ 主Agent │ ◄── 协调者,统一入口 │ │
|
||||
│ │ │(调度员) │ │ │
|
||||
│ │ └────┬────┘ │ │
|
||||
│ │ ├──► 规划Agent ──► 任务拆解、计划制定 │ │
|
||||
│ │ ├──► 执行Agent ──► 工具调用、任务执行 │ │
|
||||
│ │ ├──► 知识管理员 ──► 知识库管理、图谱更新 │ │
|
||||
│ │ └──► 分析师Agent ──► 数据分析、报告生成 │ │
|
||||
│ │ └──► [可扩展] ────► 新角色注册机制 │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
|
||||
│ │ LLM 适配器 │ │ 定时任务 │ │ 论坛扫描 │ │
|
||||
│ │ LangChain │ │ 引擎 │ │ 引擎 │ │
|
||||
│ │ (可切换) │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └──────────────┘ │
|
||||
└──────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌─────────────▼────┐ ┌──────────▼────────┐
|
||||
│ ChromaDB│ │ SQLite │ │ 文件存储 │
|
||||
│向量数据库│ │ (关系数据) │ │ (NAS 共享目录) │
|
||||
└─────────┘ └───────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 通信模式
|
||||
|
||||
- **协作式 + 主 Agent 协调**
|
||||
- 主 Agent 作为统一入口,接收用户请求后分发到子 Agent
|
||||
- 子 Agent 完成任务后汇总结果给主 Agent
|
||||
- 子 Agent 之间可通过主 Agent 传递信息
|
||||
- 支持新增 Agent 注册到系统中
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心功能模块
|
||||
|
||||
### 4.1 多 Agent 调度系统
|
||||
|
||||
#### Agent 角色定义
|
||||
|
||||
| Agent | 职责 | 核心能力 |
|
||||
|-------|------|---------|
|
||||
| **主Agent (Jarvis)** | 协调调度、对话入口 | 意图识别、任务分发、结果汇总 |
|
||||
| **规划Agent** | 制定每日计划 | 任务拆解、优先级排序、时间规划 |
|
||||
| **执行Agent** | 执行具体任务 | 工具调用、进度追踪、结果反馈 |
|
||||
| **知识管理员** | 管理知识库和图谱 | 文档索引、实体提取、图谱更新 |
|
||||
| **分析师Agent** | 分析工作数据 | 数据统计、趋势分析、报告生成 |
|
||||
|
||||
#### Agent 扩展机制
|
||||
|
||||
- 通过配置文件或 API 注册新 Agent
|
||||
- 每个 Agent 有独立的 system prompt 和工具集
|
||||
- 新增 Agent 自动出现在对话上下文中
|
||||
|
||||
### 4.2 知识库系统
|
||||
|
||||
#### 文档处理流程
|
||||
|
||||
```
|
||||
用户上传文件
|
||||
│
|
||||
▼
|
||||
文件解析
|
||||
├── Markdown → 直接读取
|
||||
├── PDF → PDF 解析(PyMuPDF)
|
||||
├── DOCX → python-docx
|
||||
└── TXT → 直接读取
|
||||
│
|
||||
▼
|
||||
LlamaIndex Node 构建
|
||||
├── 按标题层级切分(Header-based Chunking)
|
||||
├── 保留 Node 关系链表(PARENT, PREVIOUS, NEXT, SOURCE)
|
||||
└── 每个 Node 包含 metadata(标题、章节、页码)
|
||||
│
|
||||
▼
|
||||
向量存储 → ChromaDB
|
||||
│
|
||||
▼
|
||||
知识图谱构建
|
||||
├── LLM 实体识别(从 Node 内容中提取)
|
||||
├── LLM 关系抽取(实体之间的关系)
|
||||
└── 存入 SQLite(nodes + edges 表)
|
||||
```
|
||||
|
||||
#### 检索流程(Small-to-Big 策略)
|
||||
|
||||
```
|
||||
用户提问
|
||||
│
|
||||
▼
|
||||
ChromaDB 向量检索
|
||||
├── 用小 Chunk 精确匹配
|
||||
└── 返回多个相关 Node
|
||||
│
|
||||
▼
|
||||
上下文回溯
|
||||
├── 顺着 Node 关系找到完整章节(父 Node)
|
||||
└── 附加上下文给 LLM
|
||||
│
|
||||
▼
|
||||
LLM 生成回答
|
||||
```
|
||||
|
||||
### 4.3 知识图谱系统
|
||||
|
||||
#### 图谱数据结构
|
||||
|
||||
```sql
|
||||
-- 知识图谱节点表
|
||||
knowledge_graph_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离(支持多用户)
|
||||
entity_type TEXT, -- 实体类型:PERSON / EVENT / CONCEPT / OBJECT
|
||||
entity_name TEXT, -- 实体名称
|
||||
description TEXT, -- 实体描述
|
||||
source_doc_id TEXT, -- 来源文档
|
||||
source_node_id TEXT, -- 来源 Node
|
||||
importance REAL, -- 重要程度 (0-1)
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
|
||||
-- 知识图谱边表
|
||||
knowledge_graph_edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离
|
||||
source_node_id TEXT,
|
||||
target_node_id TEXT,
|
||||
relation_type TEXT, -- 关系类型:包含 / 依赖 / 相关 / 导致 / 属于
|
||||
weight REAL, -- 关系权重 (0-1)
|
||||
created_at TIMESTAMP,
|
||||
FOREIGN KEY (source_node_id) REFERENCES knowledge_graph_nodes(id),
|
||||
FOREIGN KEY (target_node_id) REFERENCES knowledge_graph_nodes(id)
|
||||
)
|
||||
```
|
||||
|
||||
#### 图谱更新机制
|
||||
|
||||
- **事件驱动**:文档上传/任务变更时实时触发
|
||||
- **定时同步**:每日凌晨增量扫描,防止遗漏
|
||||
- **手动触发**:用户可主动要求重建图谱
|
||||
- **增量检测**:基于文件 mtime + 内容 hash 判断文档是否变化
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```sql
|
||||
-- 文档表
|
||||
documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离
|
||||
filename TEXT,
|
||||
file_type TEXT, -- pdf / markdown / docx / txt
|
||||
file_path TEXT, -- NAS 存储路径
|
||||
file_hash TEXT, -- 内容 hash,用于增量检测
|
||||
summary TEXT, -- AI 生成的文档摘要
|
||||
file_size INTEGER,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
|
||||
-- 文档分块表(LlamaIndex Node 映射)
|
||||
document_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离
|
||||
document_id TEXT,
|
||||
chunk_index INTEGER, -- 在文档中的顺序
|
||||
content TEXT, -- 原始文本内容
|
||||
metadata JSON, -- LlamaIndex Node metadata(包含 title、chapter、relationships 等)
|
||||
embedding_id TEXT, -- ChromaDB 中的向量 ID
|
||||
created_at TIMESTAMP,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id)
|
||||
)
|
||||
```
|
||||
|
||||
#### 图谱可视化
|
||||
|
||||
- 前端 Web 端展示交互式知识图谱
|
||||
- 节点可点击查看详情
|
||||
- 支持按类型筛选、按时间筛选
|
||||
- 支持搜索实体名称
|
||||
|
||||
### 4.4 论坛系统
|
||||
|
||||
#### 功能设计
|
||||
|
||||
- **发布内容** — 你在论坛发布想法、指令、问题
|
||||
- **AI 扫描** — Jarvis 定时扫描论坛内容
|
||||
- **任务识别** — 识别可执行的任务转为看板任务
|
||||
- **互动回应** — AI 在帖子下回复,像团队成员讨论
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```sql
|
||||
forum_posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 发帖用户
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
parent_id TEXT, -- 回复的帖子 ID(自关联,支持嵌套回复)
|
||||
status TEXT, -- pending / processing / completed
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
```
|
||||
|
||||
### 4.5 看板系统
|
||||
|
||||
#### 功能设计
|
||||
|
||||
- **任务管理** — 创建、编辑、删除任务
|
||||
- **状态流转** — 待办 / 进行中 / 已完成 / 已取消
|
||||
- **优先级** — P0 / P1 / P2 / P3
|
||||
- **AI 凌晨分析** — 每日凌晨分析完成情况,规划次日任务
|
||||
- **AI 建议** — 根据你的工作模式给出优先级建议
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```sql
|
||||
tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
priority TEXT, -- P0 / P1 / P2 / P3
|
||||
status TEXT, -- todo / in_progress / done / cancelled
|
||||
deadline TIMESTAMP,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
)
|
||||
|
||||
task_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT,
|
||||
action TEXT, -- created / updated / completed / cancelled
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
timestamp TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id)
|
||||
)
|
||||
```
|
||||
|
||||
### 4.6 Markdown 编辑器
|
||||
|
||||
#### 功能设计
|
||||
|
||||
- 浏览器端在线编辑 Markdown
|
||||
- 支持实时预览
|
||||
- AI 辅助功能:
|
||||
- AI 续写
|
||||
- AI 润色
|
||||
- AI 总结
|
||||
- 自动保存到知识库
|
||||
- 支持创建新文档和编辑已有文档
|
||||
|
||||
### 4.7 定时任务引擎
|
||||
|
||||
#### 内置定时任务
|
||||
|
||||
| 任务 | 触发时间 | 功能 |
|
||||
|------|---------|------|
|
||||
| 论坛扫描 | 可配置(默认每小时) | 扫描新帖子,识别可执行任务 |
|
||||
| 图谱增量同步 | 每日凌晨 2:00 | 扫描文档变化,更新知识图谱 |
|
||||
| 每日规划 | 每日早上 8:00 | 分析昨日任务完成情况,规划当日 |
|
||||
| 知识摘要 | 每周一凌晨 | 生成上周工作摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库设计
|
||||
|
||||
### 5.1 ER 图
|
||||
|
||||
```
|
||||
users
|
||||
│
|
||||
▼
|
||||
documents ──► document_chunks ──► embeddings (ChromaDB)
|
||||
│
|
||||
▼
|
||||
knowledge_graph_nodes ◄──► knowledge_graph_edges
|
||||
│
|
||||
▼
|
||||
tasks ◄─── task_history
|
||||
│
|
||||
▼
|
||||
forum_posts (自关联: parent_id ──► forum_posts.id)
|
||||
│
|
||||
▼
|
||||
conversations ──► messages
|
||||
```
|
||||
|
||||
### 5.2 核心表结构
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `users` | 用户信息 |
|
||||
| `documents` | 上传的文档元数据 |
|
||||
| `document_chunks` | LlamaIndex Node 映射(保留关系) |
|
||||
| `knowledge_graph_nodes` | 知识图谱节点 |
|
||||
| `knowledge_graph_edges` | 知识图谱边 |
|
||||
| `tasks` | 看板任务 |
|
||||
| `task_history` | 任务变更历史 |
|
||||
| `forum_posts` | 论坛帖子(含回复,通过 parent_id 自关联) |
|
||||
| `conversations` | 主对话会话 |
|
||||
| `messages` | 对话消息 |
|
||||
| `knowledge_summaries` | 历史对话摘要 |
|
||||
|
||||
#### 对话数据模型
|
||||
|
||||
```sql
|
||||
conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- 用户隔离
|
||||
title TEXT,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
|
||||
messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
role TEXT, -- user / assistant
|
||||
content TEXT,
|
||||
model TEXT, -- 使用的模型
|
||||
created_at TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
|
||||
)
|
||||
|
||||
knowledge_summaries (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
period TEXT, -- daily / weekly / monthly
|
||||
period_start DATE,
|
||||
period_end DATE,
|
||||
summary TEXT, -- 摘要内容
|
||||
created_at TIMESTAMP
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 主要 API 端点
|
||||
|
||||
> **通用规则**:所有列表接口支持分页参数 `page`(页码,默认 1)和 `page_size`(每页数量,默认 20)。返回格式统一为 `{ data: [...], total: N, page: X, page_size: Y }`。
|
||||
|
||||
#### 认证接口
|
||||
- `POST /api/auth/register` — 用户注册
|
||||
- `POST /api/auth/login` — 用户登录,返回 JWT Token
|
||||
- `POST /api/auth/refresh` — 刷新 Token
|
||||
- `POST /api/auth/logout` — 登出
|
||||
|
||||
#### 对话接口
|
||||
- `POST /api/chat` — 发送消息,获取 AI 回复
|
||||
- `GET /api/conversations?page=&page_size=` — 获取对话历史列表
|
||||
- `GET /api/conversations/{id}/messages?page=&page_size=` — 获取对话消息
|
||||
|
||||
#### 知识库接口
|
||||
- `POST /api/documents/upload` — 上传文档(支持 multipart/form-data,最大 50MB)
|
||||
- `GET /api/documents?page=&page_size=` — 获取文档列表
|
||||
- `DELETE /api/documents/{id}` — 删除文档
|
||||
- `POST /api/documents/{id}/reindex` — 重建索引(幂等操作)
|
||||
- `POST /api/search` — 语义搜索
|
||||
- 请求体:`{ "query": "搜索内容", "top_k": 5, "filters": {} }`
|
||||
|
||||
#### 知识图谱接口
|
||||
- `GET /api/knowledge-graph` — 获取图谱数据
|
||||
- `POST /api/knowledge-graph/rebuild` — 触发图谱重建(幂等,带锁防止并发)
|
||||
- `GET /api/knowledge-graph/search?q=` — 搜索实体
|
||||
|
||||
#### 看板接口
|
||||
- `GET /api/tasks?page=&page_size=&status=` — 获取任务列表
|
||||
- `POST /api/tasks` — 创建任务
|
||||
- `PUT /api/tasks/{id}` — 更新任务
|
||||
- `DELETE /api/tasks/{id}` — 删除任务
|
||||
|
||||
#### 论坛接口
|
||||
- `GET /api/forum/posts?page=&page_size=` — 获取帖子列表
|
||||
- `POST /api/forum/posts` — 发布帖子
|
||||
- `GET /api/forum/posts/{id}` — 获取帖子详情(含回复树)
|
||||
- `POST /api/forum/posts/{id}/reply` — 回复帖子
|
||||
|
||||
#### Markdown 编辑器接口
|
||||
- `GET /api/notes?page=&page_size=` — 获取笔记列表
|
||||
- `POST /api/notes` — 创建笔记
|
||||
- `PUT /api/notes/{id}` — 更新笔记
|
||||
- `DELETE /api/notes/{id}` — 删除笔记
|
||||
- `POST /api/notes/{id}/ai-assist` — AI 辅助操作
|
||||
- 请求体:`{ "action": "continue" | "polish" | "summarize" }`
|
||||
|
||||
### 6.2 WebSocket 实时通信
|
||||
|
||||
消息格式统一为 JSON:
|
||||
```json
|
||||
// 通用消息结构
|
||||
{
|
||||
"type": "chat_message" | "graph_update" | "task_update",
|
||||
"payload": { ... },
|
||||
"timestamp": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
- `/ws/chat` — 实时对话(流式输出)
|
||||
- `/ws/knowledge-graph` — 图谱更新实时推送
|
||||
- `/ws/tasks` — 任务状态变化实时推送
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端设计
|
||||
|
||||
### 7.1 Web 端页面结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 顶部导航栏 │
|
||||
│ [对话] [知识库] [图谱] [看板] [论坛] [笔记] │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 主内容区域 │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 核心页面
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **对话页** | 主对话界面,Jarvis 头像,消息列表,输入框 |
|
||||
| **知识库页** | 文档列表,上传入口,搜索框 |
|
||||
| **图谱页** | 交互式知识图谱,节点详情侧边栏 |
|
||||
| **看板页** | 任务看板(Kanban 布局),AI 规划建议 |
|
||||
| **论坛页** | 帖子列表,发帖入口,AI 回复展示 |
|
||||
| **笔记页** | Markdown 编辑器,笔记列表 |
|
||||
|
||||
### 7.3 Android 端
|
||||
|
||||
- 独立对话窗口,直接与 Jarvis 对话
|
||||
- 任务查看和简单编辑
|
||||
- 推送通知(每日规划提醒、任务到期提醒)
|
||||
- 核心是**对话遥控**,重度操作建议用 Web 端
|
||||
|
||||
---
|
||||
|
||||
## 8. 部署架构
|
||||
|
||||
### 8.1 NAS 部署方案
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ NAS │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Docker Compose │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ Jarvis API │ │ ChromaDB │ │ │
|
||||
│ │ │ (FastAPI) │ │ (向量数据) │ │ │
|
||||
│ │ └──────────────┘ └──────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ SQLite │ │ 文件存储 │ │ │
|
||||
│ │ │ (关系数据) │ │ /data/files │ │ │
|
||||
│ │ └──────────────┘ └──────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ NAS 共享目录 /data 挂载到容器 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 环境变量配置
|
||||
|
||||
```env
|
||||
# LLM 配置
|
||||
LLM_PROVIDER=claude # claude / deepseek / ollama
|
||||
CLAUDE_API_KEY=xxx
|
||||
DEEPSEEK_API_KEY=xxx
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite+aiosqlite:///data/jarvis.db
|
||||
CHROMA_PERSIST_DIR=/data/chroma
|
||||
|
||||
# 文件存储
|
||||
FILE_STORAGE_DIR=/data/files
|
||||
|
||||
# 定时任务配置
|
||||
FORUM_SCAN_INTERVAL=3600 # 秒
|
||||
DAILY_PLAN_TIME=08:00
|
||||
GRAPH_SYNC_TIME=02:00
|
||||
|
||||
# JWT 认证
|
||||
JWT_SECRET=xxx
|
||||
JWT_ALGORITHM=HS256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全设计
|
||||
|
||||
- **JWT 认证** — 所有 API 需要 Token 验证
|
||||
- **数据加密** — SQLite 数据库可配置加密
|
||||
- **文件隔离** — 用户上传文件存储在独立目录
|
||||
- **API 限流** — 防止 API 滥用
|
||||
- **敏感信息** — API Key 等存储在环境变量,不进入代码库
|
||||
|
||||
---
|
||||
|
||||
## 10. 未来扩展方向
|
||||
|
||||
- **多模态支持** — 图片、音频、视频解析
|
||||
- **更多 Agent** — 按领域细分的专业助手
|
||||
- **插件系统** — 第三方工具集成
|
||||
- **团队协作** — 多用户知识共享
|
||||
- **云端同步** — 异地数据备份
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发阶段建议
|
||||
|
||||
> **注意**:Phase 3 的知识图谱依赖 Phase 1 的知识库基础设施。Phase 1-3 为核心 MVP,需按顺序开发。
|
||||
|
||||
| 阶段 | 内容 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **Phase 1** | 基础框架搭建、对话系统、知识库上传检索 | P0 |
|
||||
| **Phase 2** | 看板系统、论坛系统、Markdown 编辑器 | P0 |
|
||||
| **Phase 3** | 知识图谱构建与可视化、多 Agent 协同 | P0 |
|
||||
| **Phase 4** | 定时任务引擎、AI 每日规划功能 | P1 |
|
||||
| **Phase 5** | Android App 开发 | P1 |
|
||||
| **Phase 6** | 优化与扩展 | P2 |
|
||||
|
||||
---
|
||||
|
||||
*本文档为 Jarvis 个人 AI 助理系统的初始设计规格,将根据开发进展持续更新。*
|
||||
@@ -1,57 +0,0 @@
|
||||
# Notes: Jarvis Knowledge Brain Blueprint
|
||||
|
||||
## Current-State Findings
|
||||
- Existing source domains already exist separately: conversations, documents, todos, tasks, forum posts.
|
||||
- Current long-term memory only comes from conversation extraction via `UserMemory`.
|
||||
- Current graph build path only uses indexed document chunks.
|
||||
- Scheduler infrastructure already exists and can host daily brain-learning jobs.
|
||||
- Frontend already exposes a `知识大脑` navigation entry, but it currently points to the graph page.
|
||||
|
||||
## Synthesized Findings
|
||||
|
||||
### What can be reused
|
||||
- `memory_service` as a seed for conversation extraction and recall.
|
||||
- `scheduler_service` as the base for daily learning workflows.
|
||||
- `tag_service` as an early foundation for brain tags.
|
||||
- Existing business tables as authoritative raw source records.
|
||||
|
||||
### What is missing
|
||||
- Unified event layer across all source systems.
|
||||
- Candidate memory layer between raw events and durable brain memory.
|
||||
- Timeline-aware memory model with reinforcement / archival states.
|
||||
- Retrieval path that combines long-term memory with recent relevant events.
|
||||
- Brain-specific APIs and a dedicated frontend dashboard module.
|
||||
|
||||
### Phase 1 objective
|
||||
- Build the minimum architecture needed for a real event-driven brain:
|
||||
- BrainEvent
|
||||
- BrainCandidate
|
||||
- BrainMemory
|
||||
- BrainTag and link tables
|
||||
- ingestion services
|
||||
- daily learning job
|
||||
- retrieval integration
|
||||
- brain dashboard APIs
|
||||
|
||||
## Additional Findings: Knowledge Parsing Normalization
|
||||
- Current document ingestion parses each format separately and builds chunks directly from ParsedNode items.
|
||||
- Current chunks already carry structural metadata, but there is no explicit parent-child chunk graph.
|
||||
- The agreed direction is to use MinerU for PDF only, keep existing parsers for DOCX/XLSX/CSV/MD/TXT, and converge all outputs into structured markdown.
|
||||
- normalized_content should be persisted on documents so preview, rebuild, and future chunking can reuse the same canonical text.
|
||||
- Lightweight hierarchy should be represented in chunk metadata first, not in a new relational tree schema.
|
||||
- Current DOCX upload failure in the running environment is caused by a missing python-docx installation in the active backend environment.
|
||||
|
||||
## Additional Findings: L3 Merge Progress
|
||||
- `backend/app/agents/state.py` has been expanded to the newer L3 runtime state shape so graph/runtime code can rely on structured continuity, tool-round, retry, routing-hop, and datetime-reference fields.
|
||||
- `backend/app/agents/graph.py` no longer contains merge markers and the phantom `EXECUTOR_ACCOUNTING` branch has been removed from graph registration and routing.
|
||||
- Accounting-style prompts are currently normalized onto `AgentRole.EXECUTOR` instead of a separate executor-accounting role, which avoids dangling enum/runtime references while keeping those intents routable.
|
||||
- `backend/tests/backend/app/agents/test_graph.py` has been reconciled onto the newer L3 runtime test branch and stale `EXECUTOR_ACCOUNTING` expectations were updated to `AgentRole.EXECUTOR`.
|
||||
- Tool execution now uses a shared async bridge in `backend/app/agents/tools/async_bridge.py`, and `search.py`, `schedule.py`, `task.py`, plus `forum.py` all route synchronous tool entrypoints through that same bridge to keep runtime behavior consistent inside and outside active event loops.
|
||||
- Current task/schedule canonicalization remains intentionally narrow for L3: task aliases (`content`, `date`, legacy priorities) and reminder aliases (`datetime`, `at`, `remind_at`, `time`, timezone variants) are normalized; deferred domains such as weather/accounting-specific tool routing remain outside this stabilization slice.
|
||||
- Targeted verification now covers async bridge behavior plus task/schedule alias persistence tests; local pytest invocation still depends on resolving environment-level startup issues when the interpreter exits before running the selected files.
|
||||
- L3 runtime/service integration now persists continuity snapshots in a single canonical envelope (`kind`, `version`, `state`) on both assistant message attachments and `Conversation.agent_state`, so streaming and sync chat entrypoints rehydrate the same shape.
|
||||
- The continuity rehydration path is also tolerant of older `Conversation` rows/models that do not expose `agent_state`, falling back to assistant message attachments instead of failing before graph execution.
|
||||
- The finalized L3 continuity contract persists a canonical `agent_continuity_state` snapshot: `turn_context.active_sub_commander`, `pending_action.type|owner_agent|owner_sub_commander|status`, `clarification_context.owning_agent|owning_sub_commander|target_action|question|status`, and `continuity_state.status|mode`.
|
||||
- `backend/app/services/agent_service.py` normalizes legacy persisted snapshots (`active_sub_flow`, `agent`, `sub_flow`, `action_type`, `awaiting_user_input`, `awaiting_clarification`) into that canonical shape on both save and rehydration so older brain-ingestion records still resume correctly.
|
||||
- Edge cases: explicit new requests may keep stale continuity in memory for override-aware routing, but only `continuity_state.status == fresh` participates in active continuation; clarification resumes use `continuity_state.mode = resume_after_clarification`.
|
||||
- `memory_service.build_memory_context(...)` remains the shared retrieval join point for conversation summaries, user memory, and BrainMemory recall, while `document_service` continues emitting BrainEvent records from upload flow without changing the graph runtime contract.
|
||||
@@ -1,427 +0,0 @@
|
||||
# Jarvis Knowledge Brain Phase 1 Blueprint
|
||||
|
||||
## 1. Phase 1 Goal
|
||||
Phase 1 establishes the first production-ready version of Jarvis's event-driven knowledge brain. The objective is not to finish the entire intelligence system, but to create the minimum architecture that lets Jarvis ingest key user actions from across the product, learn from them on a daily schedule, store only high-value knowledge, and retrieve that knowledge during future conversations.
|
||||
|
||||
Phase 1 should make the brain real in six ways:
|
||||
1. unify source events across core modules;
|
||||
2. create an intermediate candidate-learning layer;
|
||||
3. promote durable knowledge into long-term brain memory;
|
||||
4. maintain tags and time-aware traceability;
|
||||
5. expose APIs for inspection and management;
|
||||
6. allow the chat system to retrieve brain knowledge during answers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope Boundaries
|
||||
|
||||
### In scope
|
||||
- New persistence models for brain events, candidates, memories, tags, and relationships.
|
||||
- Ingestion of source signals from conversations, knowledge documents, todos, kanban tasks, and forum posts.
|
||||
- A daily autonomous learning pipeline that tags, scores, deduplicates, and upgrades knowledge.
|
||||
- Retrieval integration for future responses.
|
||||
- Brain dashboard APIs.
|
||||
- A new frontend brain module structure replacing the current graph-only mental model.
|
||||
|
||||
### Out of scope for phase 1
|
||||
- Full graph-native reasoning engine.
|
||||
- Fully autonomous suggestion orchestration across all screens.
|
||||
- Complex reinforcement-learning style adaptation.
|
||||
- Fine-grained user-tunable learning policy UI.
|
||||
- Automatic deletion and archival heuristics beyond simple status transitions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Architecture
|
||||
Phase 1 should introduce a four-layer brain pipeline:
|
||||
|
||||
1. **Source Records**
|
||||
Existing domain tables remain the source of truth: messages, documents/chunks, todos, tasks, forum posts/replies.
|
||||
|
||||
2. **BrainEvent**
|
||||
A normalized event layer representing meaningful user/system actions. This is the single intake format for downstream learning.
|
||||
|
||||
3. **BrainCandidate**
|
||||
AI-generated candidate knowledge distilled from one or more events. Candidates are scored, tagged, typed, and traced back to source events.
|
||||
|
||||
4. **BrainMemory**
|
||||
Durable long-term memory that Jarvis can retrieve during future interactions. This becomes the brain's core persistence layer.
|
||||
|
||||
Graph visualization should be treated as a **projection layer**, not the primary storage model. In later phases, graph nodes and edges can be generated from BrainMemory records and their relationships.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model Additions
|
||||
|
||||
### 4.1 BrainEvent
|
||||
Purpose: normalized raw learning input.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `source_type` (`conversation`, `document`, `todo`, `task`, `forum_post`, `forum_reply`)
|
||||
- `source_id`
|
||||
- `event_type` (`created`, `updated`, `completed`, `mentioned`, `uploaded`, `resolved`, `marked_important`, etc.)
|
||||
- `occurred_at`
|
||||
- `event_date`
|
||||
- `title`
|
||||
- `content_summary`
|
||||
- `raw_excerpt`
|
||||
- `metadata_` (JSON; source-specific facts such as conversation_id, task status, folder path)
|
||||
- `importance_signal` (numeric seed score)
|
||||
- `is_user_pinned`
|
||||
- `processed_at`
|
||||
- `status` (`pending`, `processed`, `ignored`)
|
||||
|
||||
Indexes:
|
||||
- `(user_id, event_date)`
|
||||
- `(user_id, source_type, source_id)`
|
||||
- `(user_id, status, occurred_at)`
|
||||
|
||||
### 4.2 BrainCandidate
|
||||
Purpose: intermediate learned knowledge awaiting acceptance into durable memory.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `candidate_type` (`preference`, `habit`, `project_fact`, `decision`, `solution`, `topic`, `goal`, `temporary_focus`)
|
||||
- `title`
|
||||
- `summary`
|
||||
- `importance_score`
|
||||
- `confidence_score`
|
||||
- `time_scope` (`short_term`, `phase`, `long_term`)
|
||||
- `valid_from`
|
||||
- `valid_to`
|
||||
- `source_event_ids` (JSON array)
|
||||
- `reasoning_trace` (short explanation of why the system extracted it)
|
||||
- `status` (`new`, `promoted`, `rejected`, `merged`)
|
||||
- `created_at`
|
||||
- `reviewed_at`
|
||||
|
||||
### 4.3 BrainMemory
|
||||
Purpose: durable brain knowledge used at retrieval time.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `memory_type` (`preference`, `habit`, `goal`, `project_fact`, `decision`, `solution`, `topic_profile`)
|
||||
- `title`
|
||||
- `content`
|
||||
- `importance`
|
||||
- `confidence`
|
||||
- `timeline_date`
|
||||
- `first_learned_at`
|
||||
- `last_reinforced_at`
|
||||
- `reinforcement_count`
|
||||
- `status` (`active`, `archived`, `deleted`)
|
||||
- `origin_candidate_id`
|
||||
- `origin_source_types` (JSON array)
|
||||
- `metadata_` (JSON)
|
||||
|
||||
### 4.4 BrainTag
|
||||
Purpose: independent tagging layer for brain browsing, filtering, and scoring.
|
||||
|
||||
Recommended fields:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `name`
|
||||
- `category` (`topic`, `value`, `time`, `source`)
|
||||
- `priority` (`important`, `secondary`)
|
||||
- `score`
|
||||
- `last_seen_at`
|
||||
- `created_at`
|
||||
|
||||
### 4.5 Link Tables
|
||||
Add many-to-many link tables:
|
||||
- `brain_event_tags`
|
||||
- `brain_candidate_tags`
|
||||
- `brain_memory_tags`
|
||||
- optional `brain_memory_events` for direct memory-to-event traceability beyond JSON arrays
|
||||
|
||||
These link tables are critical because phase 1 needs tag filters and timeline tracing before advanced graph projection exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ingestion Strategy
|
||||
Phase 1 should not rewrite existing modules. Instead, it should add thin ingestion hooks near existing write paths.
|
||||
|
||||
### Conversation ingestion
|
||||
Trigger points:
|
||||
- after user message creation
|
||||
- after assistant completion
|
||||
- after memory extraction / summary creation
|
||||
|
||||
Event examples:
|
||||
- important user instruction
|
||||
- explicit “remember this” request
|
||||
- repeated topic cluster
|
||||
- conversation-derived decision or unresolved goal
|
||||
|
||||
### Document ingestion
|
||||
Trigger points:
|
||||
- after upload success
|
||||
- after indexing completes
|
||||
- after manual chunk edits
|
||||
|
||||
Event examples:
|
||||
- document uploaded
|
||||
- document indexed
|
||||
- high-value section discovered
|
||||
- document summary available
|
||||
|
||||
### Todo ingestion
|
||||
Trigger points:
|
||||
- todo created
|
||||
- todo completed
|
||||
- AI-generated todo created
|
||||
|
||||
Event examples:
|
||||
- planned work item
|
||||
- recurring operational duty
|
||||
- completion signal reflecting actual user focus
|
||||
|
||||
### Task/Kanban ingestion
|
||||
Trigger points:
|
||||
- task created
|
||||
- task status changed
|
||||
- task completed
|
||||
- priority changed
|
||||
|
||||
Event examples:
|
||||
- declared project goal
|
||||
- active workstream
|
||||
- resolved milestone
|
||||
|
||||
### Forum ingestion
|
||||
Trigger points:
|
||||
- post created
|
||||
- reply created
|
||||
- forum instruction executed or referenced
|
||||
|
||||
Event examples:
|
||||
- public project decision
|
||||
- repeated operational issue
|
||||
- reusable explanation or solution
|
||||
|
||||
Implementation note: source ingestion should create BrainEvent rows synchronously or via lightweight background tasks, but should not block the original user flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Learning and Promotion Pipeline
|
||||
Phase 1 should add a new daily scheduler workflow dedicated to the brain.
|
||||
|
||||
### New scheduler job: `brain_daily_learning_task`
|
||||
Suggested run: once daily after the bulk of user activity, for example 01:00 or configurable per user later.
|
||||
|
||||
Pipeline steps:
|
||||
1. collect unprocessed `BrainEvent` rows for the target date;
|
||||
2. cluster by source, topic, and repeated patterns;
|
||||
3. ask the LLM to produce candidate knowledge with tags and importance explanations;
|
||||
4. deduplicate against existing `BrainMemory` by semantic and rule-based matching;
|
||||
5. promote high-confidence candidates into `BrainMemory`;
|
||||
6. mark low-value candidates rejected or retained as observation-only;
|
||||
7. refresh tag scores and priority levels;
|
||||
8. mark consumed events as processed.
|
||||
|
||||
### Promotion rules for phase 1
|
||||
Promote automatically when any of these are true:
|
||||
- user explicitly requested the system to remember something;
|
||||
- the same topic appears across multiple sources;
|
||||
- a solution/decision was formed and looks reusable;
|
||||
- a stable preference or habit is seen repeatedly;
|
||||
- a task/todo/forum thread confirms relevance with user action.
|
||||
|
||||
Keep as candidate-only when:
|
||||
- information is recent but not yet stable;
|
||||
- importance is uncertain;
|
||||
- it appears only once without reinforcement.
|
||||
|
||||
Reject when:
|
||||
- content is obviously transient;
|
||||
- it is too generic to help future answers;
|
||||
- it duplicates active memory without adding new value.
|
||||
|
||||
---
|
||||
|
||||
## 7. Retrieval Integration
|
||||
Phase 1 must let chat use the brain in a controlled way.
|
||||
|
||||
### New retrieval service
|
||||
Add a dedicated `brain_retrieval_service` or extend `memory_service` with brain-aware retrieval APIs.
|
||||
|
||||
Responsibilities:
|
||||
- retrieve top relevant `BrainMemory` rows by query, tags, time context, and importance;
|
||||
- optionally retrieve recent `BrainEvent` summaries for recency-sensitive answers;
|
||||
- merge existing `UserMemory` and `MemorySummary` into one retrieval result shape;
|
||||
- support limits to avoid prompt bloat.
|
||||
|
||||
### Retrieval policy
|
||||
At answer time:
|
||||
- always consider long-term `BrainMemory`;
|
||||
- include recent event summaries only when the question appears time-sensitive or project-state-sensitive;
|
||||
- cap injected brain context to a small curated set.
|
||||
|
||||
Recommended first integration path:
|
||||
- extend `build_memory_context()` to append a new `【知识大脑】` block built from `BrainMemory` retrieval.
|
||||
- keep existing conversation summary logic intact.
|
||||
|
||||
This gives immediate product value without requiring a full prompt orchestration rewrite.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backend Services to Add or Refactor
|
||||
|
||||
### New services
|
||||
1. `brain_event_service.py`
|
||||
- normalize incoming source data into BrainEvent rows
|
||||
- provide source-specific helper constructors
|
||||
|
||||
2. `brain_learning_service.py`
|
||||
- run daily candidate extraction
|
||||
- score, dedupe, and promote memories
|
||||
|
||||
3. `brain_tag_service.py`
|
||||
- manage tags, scoring, priority updates, and cleanup suggestions
|
||||
|
||||
4. `brain_retrieval_service.py`
|
||||
- retrieve relevant memories and recent events for chat and UI
|
||||
|
||||
### Existing services to extend
|
||||
- `memory_service.py`: integrate BrainMemory retrieval and possibly migrate `UserMemory` into the new model later
|
||||
- `scheduler_service.py`: register brain daily learning job
|
||||
- `agent_service.py`: inject retrieved brain context into chat pipeline
|
||||
- `document_service.py`, `todo_service.py`, task/forum write paths: emit BrainEvent rows
|
||||
|
||||
---
|
||||
|
||||
## 9. API Plan
|
||||
Phase 1 should add a dedicated `/api/brain` router.
|
||||
|
||||
### Read APIs
|
||||
- `GET /api/brain/overview`
|
||||
- counts: active memories, candidates, important tags, recent events
|
||||
- today's learning summary
|
||||
|
||||
- `GET /api/brain/memories`
|
||||
- filters: tag, type, status, date range, source type
|
||||
|
||||
- `GET /api/brain/candidates`
|
||||
- filters: status, date, score threshold
|
||||
|
||||
- `GET /api/brain/tags`
|
||||
- segmented into important and secondary
|
||||
|
||||
- `GET /api/brain/timeline`
|
||||
- grouped by day/week; includes events, candidate promotions, reinforced memories
|
||||
|
||||
- `GET /api/brain/memory/{id}`
|
||||
- full traceability including linked events and tags
|
||||
|
||||
### Write/management APIs
|
||||
- `POST /api/brain/memory/{id}/promote`
|
||||
- `POST /api/brain/memory/{id}/archive`
|
||||
- `DELETE /api/brain/memory/{id}`
|
||||
- `POST /api/brain/tag/{id}/promote`
|
||||
- `POST /api/brain/tag/{id}/demote`
|
||||
- `DELETE /api/brain/tag/{id}`
|
||||
- `POST /api/brain/learn/run`
|
||||
- manual trigger for daily learning pipeline
|
||||
|
||||
### Compatibility note
|
||||
Do not remove `/api/graph` in phase 1. Keep it as a legacy projection route while the new brain module is introduced.
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend Module Structure
|
||||
The current `知识大脑` nav item should stop meaning “graph only” and become a real brain dashboard.
|
||||
|
||||
### Route strategy
|
||||
Preferred phase 1 structure:
|
||||
- `/brain` → new knowledge brain dashboard
|
||||
- `/graph` → graph view tab or subview under the brain module, retained for relation visualization
|
||||
|
||||
### Brain dashboard sections
|
||||
1. **Overview header**
|
||||
- total active memories
|
||||
- today's learned items
|
||||
- important tags count
|
||||
- last learning run
|
||||
|
||||
2. **Important tags panel**
|
||||
- AI-ranked important tags
|
||||
- click to filter related memories and timeline entries
|
||||
|
||||
3. **Secondary tags panel**
|
||||
- lower-priority tags with cleanup actions
|
||||
|
||||
4. **Recent learned knowledge**
|
||||
- newly promoted memories
|
||||
- reasons and source badges
|
||||
|
||||
5. **Timeline panel**
|
||||
- daily grouped events and promotions
|
||||
- support time-based backtracking
|
||||
|
||||
6. **Graph subview**
|
||||
- optional tab or secondary panel for relation projection
|
||||
|
||||
### User actions in phase 1
|
||||
- delete memory
|
||||
- archive memory
|
||||
- promote/demote tag priority
|
||||
- manually trigger learning run
|
||||
- inspect why a memory exists
|
||||
|
||||
This is enough to make the brain visible and manageable even before advanced graph reasoning exists.
|
||||
|
||||
---
|
||||
|
||||
## 11. Suggested Delivery Breakdown
|
||||
|
||||
### Step 1: Persistence foundation
|
||||
- add brain models and migrations
|
||||
- add SQLAlchemy registrations and schemas
|
||||
|
||||
### Step 2: Event ingestion
|
||||
- emit BrainEvent rows from conversation/document/todo/task/forum flows
|
||||
|
||||
### Step 3: Learning workflow
|
||||
- implement daily learning job and manual trigger API
|
||||
|
||||
### Step 4: Retrieval integration
|
||||
- wire BrainMemory into chat context assembly
|
||||
|
||||
### Step 5: Brain dashboard backend
|
||||
- add overview, memories, tags, timeline endpoints
|
||||
|
||||
### Step 6: Brain dashboard frontend
|
||||
- add `/brain` page and move graph into a subview or separate tab
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks and Guardrails
|
||||
|
||||
### Main risks
|
||||
- over-collection leading to noisy memories;
|
||||
- prompt bloat from injecting too much brain context;
|
||||
- duplicate memory creation across repeated daily runs;
|
||||
- unclear distinction between candidate and durable memory;
|
||||
- UI becoming graph-centric again instead of brain-centric.
|
||||
|
||||
### Guardrails
|
||||
- enforce candidate layer before promotion;
|
||||
- cap retrieval size strictly;
|
||||
- keep source traceability for every promoted memory;
|
||||
- make tag cleanup explicit in UI;
|
||||
- treat graph as a projection, not the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 13. Phase 1 Success Criteria
|
||||
Phase 1 is successful when all of the following are true:
|
||||
- the system creates normalized BrainEvent rows from all five major source domains;
|
||||
- a scheduled daily learning job produces candidates and promotes high-value memories;
|
||||
- Jarvis can retrieve durable brain memories during future answers;
|
||||
- the frontend exposes a real brain dashboard with tags, recent knowledge, and timeline;
|
||||
- users can inspect and clean what the system learned;
|
||||
- the old graph page is no longer the only visible representation of the brain.
|
||||
@@ -1,141 +0,0 @@
|
||||
# LangSmith 集成设计文档
|
||||
|
||||
**日期**: 2026-03-20
|
||||
**状态**: 设计中
|
||||
**范围**: 后端 LangGraph Agent 追踪
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
Jarvis 后端基于 LangGraph 构建了多智能体系统(Master/Planner/Executor/Librarian/Analyst),目前没有可观测性能力。
|
||||
|
||||
本次集成目标:
|
||||
1. **调用追踪** — 在 LangSmith Dashboard 查看完整的 Agent 执行轨迹
|
||||
2. **对话历史管理** — 按 run_id 聚合对话,自动存储到 LangSmith
|
||||
3. **评估支持** — 积累的对话数据可用于 LangSmith Evaluation
|
||||
|
||||
---
|
||||
|
||||
## 2. 集成方案(方案 A:最小集成)
|
||||
|
||||
### 2.1 核心思路
|
||||
|
||||
LangGraph 内置对 LangSmith 的支持,只需三步即可完成集成:
|
||||
|
||||
1. 在 `.env` 中配置 LangSmith 环境变量
|
||||
2. 在 `pyproject.toml` 中添加 `langsmith` 为直接依赖
|
||||
3. 在 `llm_service.py` 中为 LLM 调用注入 LangSmith Callback
|
||||
|
||||
LangGraph 的 `compile()` 会自动将 Callback 传递到所有节点,无需修改 `graph.py`。
|
||||
|
||||
### 2.2 环境变量
|
||||
|
||||
在 `backend/.env.example` 中新增:
|
||||
|
||||
```env
|
||||
# LangSmith Tracing
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_API_KEY=your-langsmith-api-key
|
||||
LANGSMITH_PROJECT=jarvis-agent
|
||||
```
|
||||
|
||||
### 2.3 依赖
|
||||
|
||||
在 `backend/pyproject.toml` 的 `dependencies` 中添加:
|
||||
|
||||
```toml
|
||||
"langsmith>=0.1.0",
|
||||
```
|
||||
|
||||
### 2.4 配置类变更
|
||||
|
||||
在 `backend/app/config.py` 中新增配置字段:
|
||||
|
||||
```python
|
||||
# LangSmith
|
||||
LANGSMITH_TRACING: bool = False
|
||||
LANGSMITH_API_KEY: str = ""
|
||||
LANGSMITH_PROJECT: str = "jarvis-agent"
|
||||
```
|
||||
|
||||
### 2.5 实现变更
|
||||
|
||||
#### 2.5.1 Config 层
|
||||
|
||||
在 `backend/app/config.py` 中新增配置字段:
|
||||
|
||||
```python
|
||||
LANGSMITH_TRACING: bool = False
|
||||
LANGSMITH_API_KEY: str = ""
|
||||
LANGSMITH_PROJECT: str = "jarvis-agent"
|
||||
```
|
||||
|
||||
创建 `backend/app/config_tracing.py` 作为独立的 callback 工厂模块:
|
||||
|
||||
```python
|
||||
from langchain_core.callbacks import LangChainTracer
|
||||
from app.config import settings
|
||||
|
||||
def get_langsmith_callbacks() -> list:
|
||||
if not settings.LANGSMITH_TRACING or not settings.LANGSMITH_API_KEY:
|
||||
return []
|
||||
return [LangChainTracer(project_name=settings.LANGSMITH_PROJECT)]
|
||||
```
|
||||
|
||||
#### 2.5.2 Graph 层
|
||||
|
||||
在 `backend/app/agents/graph.py` 中:
|
||||
|
||||
1. `create_agent_graph()` 新增 `callbacks` 参数,透传给 `graph.compile(callbacks=...)`
|
||||
2. `get_agent_graph()` 内部调用 `get_langsmith_callbacks()` 并与传入参数合并后传给 `create_agent_graph()`
|
||||
|
||||
LangGraph 的 `compile(callbacks=...)` 会自动将 callbacks 传播到所有节点的 LLM 调用,覆盖 Master/Planner/Executor/Librarian/Analyst 全部 5 个节点。
|
||||
|
||||
### 2.6 Streaming 兼容性
|
||||
|
||||
当前 streaming 通过 `graph.astream_events()` 实现。LangSmith Callback 会异步记录追踪数据,不影响流式输出的实时性。
|
||||
|
||||
如果需要在 streaming 过程中实时展示 trace URL,可以在 `on_chat_model_end` 事件中从 `run.id` 生成链接:
|
||||
|
||||
```python
|
||||
async for event in graph.astream_events(...):
|
||||
if event["event"] == "on_chat_model_end":
|
||||
run_id = event["data"]["output"].id # 从 response 中获取 run_id
|
||||
trace_url = f"https://smith.langchain.com/runs/{run_id}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 文件变更清单
|
||||
|
||||
| 文件 | 变更类型 |
|
||||
|---|---|
|
||||
| `backend/.env.example` | 新增 3 行环境变量 |
|
||||
| `backend/pyproject.toml` | 新增 langsmith 依赖 |
|
||||
| `backend/app/config.py` | 新增 3 个配置字段 |
|
||||
| `backend/app/config_tracing.py` | 新建,callback 工厂函数 |
|
||||
| `backend/app/agents/graph.py` | `create_agent_graph`/`get_agent_graph` 支持 callbacks |
|
||||
| `backend/app/services/agent_service.py` | `get_agent_graph()` 调用签名对齐 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 风险与限制
|
||||
|
||||
- LangSmith 免费版有追踪数量限制(详见 LangSmith 定价)
|
||||
- Streaming 模式下 trace 数据在调用结束后才完整展示
|
||||
- 需要用户自行在 [langchain.com](https://smith.langchain.com) 注册并获取 API Key
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试验证
|
||||
|
||||
集成完成后通过以下方式验证:
|
||||
|
||||
1. 设置 `LANGSMITH_TRACING=true` 并配置 API Key
|
||||
2. 发起一次 Agent 对话
|
||||
3. 在 LangSmith Dashboard 中查看对应的 trace,确认包含:
|
||||
- 5 个节点的执行记录
|
||||
- 每个节点的 LLM 输入/输出
|
||||
- 工具调用记录
|
||||
- Token 消耗统计
|
||||
@@ -1,249 +0,0 @@
|
||||
# 注册界面 + 设置界面 功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
为 Jarvis 系统添加用户注册功能和完整的设置界面。用户可以:
|
||||
- 在前端注册账号
|
||||
- 在设置界面管理个人信息和 LLM 配置
|
||||
- 配置定时任务等系统参数
|
||||
|
||||
**核心价值:** 支持多用户、每个用户独立配置自己的 LLM 提供商和参数。
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 已有的功能
|
||||
- 后端已有 `/api/auth/register` API
|
||||
- 后端使用 `pydantic-settings` 从 `.env` 读取配置
|
||||
- 前端只有登录页面,无注册入口
|
||||
|
||||
### 需要改动的地方
|
||||
- 前端 LoginView 添加注册表单
|
||||
- User 模型增加 `llm_config` 和 `scheduler_config` JSON 字段
|
||||
- 新建 Settings 路由和服务
|
||||
- 新建 SettingsView 页面
|
||||
|
||||
## 数据模型
|
||||
|
||||
### User 表扩展
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN llm_config TEXT;
|
||||
ALTER TABLE users ADD COLUMN scheduler_config TEXT;
|
||||
```
|
||||
|
||||
### 字段结构
|
||||
|
||||
**llm_config (JSON):**
|
||||
```json
|
||||
{
|
||||
"chat": {
|
||||
"provider": "openai|claude|ollama|deepseek|custom",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-..."
|
||||
},
|
||||
"vlm": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
},
|
||||
"embedding": {
|
||||
"provider": "openai",
|
||||
"model": "text-embedding-3-small",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
},
|
||||
"rerank": {
|
||||
"provider": "openai",
|
||||
"model": "bge-reranker-v2",
|
||||
"base_url": "...",
|
||||
"api_key": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**scheduler_config (JSON):**
|
||||
```json
|
||||
{
|
||||
"daily_plan_time": "08:00",
|
||||
"forum_scan_interval_minutes": 30,
|
||||
"todo_ai_generate_time": "08:00",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 注册 API (已有)
|
||||
```
|
||||
POST /api/auth/register
|
||||
Body: { email, password, full_name }
|
||||
Response: UserOut
|
||||
```
|
||||
|
||||
### 2. 获取用户设置
|
||||
```
|
||||
GET /api/settings
|
||||
Response: {
|
||||
profile: { id, email, full_name, created_at },
|
||||
llm_config: { ... },
|
||||
scheduler_config: { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 更新用户资料
|
||||
```
|
||||
PUT /api/settings/profile
|
||||
Body: { full_name?, password? }
|
||||
Response: UserOut
|
||||
```
|
||||
|
||||
### 4. 更新 LLM 配置
|
||||
```
|
||||
PUT /api/settings/llm
|
||||
Body: { chat?: {...}, vlm?: {...}, embedding?: {...}, rerank?: {...} }
|
||||
Response: { llm_config: { ... } } // 返回更新后的完整配置
|
||||
```
|
||||
|
||||
### 5. 测试 LLM 连接
|
||||
```
|
||||
POST /api/settings/llm/test
|
||||
Body: { type: "chat"|"vlm"|"embedding"|"rerank", provider, model, base_url, api_key }
|
||||
Response: { success: true, message: "连接成功" } 或 { success: false, error: "错误信息" }
|
||||
```
|
||||
|
||||
### 6. 更新定时任务配置
|
||||
```
|
||||
PUT /api/settings/scheduler
|
||||
Body: { daily_plan_time?, forum_scan_interval_minutes?, todo_ai_generate_time?, enabled? }
|
||||
Response: { scheduler_config: { ... } } // 返回更新后的完整配置
|
||||
```
|
||||
|
||||
## 前端页面
|
||||
|
||||
### LoginView.vue 改动
|
||||
- 添加"注册"和"登录"切换 Tab
|
||||
- 注册表单:邮箱、密码、确认密码、用户名
|
||||
- 复用现有 sci-fi 登录风格
|
||||
|
||||
### SettingsView.vue (新建)
|
||||
|
||||
#### 页面布局
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [⚙] SETTINGS │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ PROFILE │ │
|
||||
│ │ Email: operator@jarvis.ai │ │
|
||||
│ │ Name: [___________] │ │
|
||||
│ │ Password: [********] [Change] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ LLM CONFIGURATION │ │
|
||||
│ │ ┌─ Chat ────────────────────────────────┐ │ │
|
||||
│ │ │ Provider: [OpenAI ▼] │ │ │
|
||||
│ │ │ Model: [gpt-4o ____________] │ │ │
|
||||
│ │ │ Base URL:[https://...] ] │ │ │
|
||||
│ │ │ API Key: [•••••••••••••••••] │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ VLM ─────────────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ Embedding ───────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ │ ┌─ Rerank ──────────────────────────────┐ │ │
|
||||
│ │ │ ... (同上结构) │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ SCHEDULER │ │
|
||||
│ │ Daily Plan Time: [08:00] │ │
|
||||
│ │ Forum Scan Interval: [30] 分钟 │ │
|
||||
│ │ Todo AI Generate: [08:00] │ │
|
||||
│ │ Scheduler Enabled: [ON] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ [SAVE ALL SETTINGS] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 交互行为
|
||||
- 修改后点击"保存"按钮,按钮显示 loading 状态
|
||||
- 保存成功显示 toast 提示"保存成功"
|
||||
- 保存失败显示 toast 提示错误信息
|
||||
- 密码修改需二次确认弹窗
|
||||
- API Key 字段支持显示/隐藏切换
|
||||
- 每个模型配置卡片有独立的"测试"按钮
|
||||
- Provider 切换时自动填充默认值(如 Ollama 切换到 localhost:11434)
|
||||
- Scheduler enabled 关闭时,时间输入框显示禁用状态
|
||||
- 空配置时显示"点击配置"占位提示
|
||||
|
||||
#### 注册表单
|
||||
- 邮箱:必填,格式校验
|
||||
- 用户名:必填,2-20 字符
|
||||
- 密码:必填,最少 8 字符
|
||||
- 确认密码:必填,需与密码一致
|
||||
- 密码强度指示器(弱/中/强)
|
||||
|
||||
## 路由和侧边栏
|
||||
|
||||
### router/index.ts
|
||||
```typescript
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
}
|
||||
```
|
||||
|
||||
### SidebarNav.vue
|
||||
```typescript
|
||||
{ name: '设置', path: '/settings', icon: Settings }
|
||||
```
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 后端文件
|
||||
```
|
||||
backend/app/
|
||||
models/
|
||||
user.py # 修改:添加 llm_config, scheduler_config 字段
|
||||
schemas/
|
||||
auth.py # 修改:UserCreate 支持 full_name
|
||||
settings.py # 新建:SettingsOut, LLMConfigIn, SchedulerConfigIn
|
||||
routers/
|
||||
settings.py # 新建:settings router
|
||||
services/
|
||||
settings_service.py # 新建:设置逻辑服务
|
||||
```
|
||||
|
||||
### 前端文件
|
||||
```
|
||||
frontend/src/
|
||||
api/
|
||||
settings.ts # 新建:settings API 客户端
|
||||
views/
|
||||
LoginView.vue # 修改:添加注册 Tab
|
||||
SettingsView.vue # 新建:设置页面
|
||||
router/
|
||||
index.ts # 修改:添加 /settings 路由
|
||||
components/
|
||||
SidebarNav.vue # 修改:添加设置菜单
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
1. 注册功能正常 - 可以通过注册页面创建新账号
|
||||
2. 登录功能正常 - 新老用户都可以登录
|
||||
3. 设置页面可访问 - 登录后可进入设置页面
|
||||
4. 个人信息修改正常 - 用户名、密码可修改
|
||||
5. LLM 配置保存正常 - 四种模型配置可保存
|
||||
6. LLM 测试连接正常 - 可以验证配置是否正确
|
||||
7. 定时任务配置正常 - 时间间隔可修改
|
||||
8. 配置持久化正常 - 重新登录后配置保留
|
||||
9. UI 风格一致 - 设置页面与其他页面风格统一
|
||||
10. 注册表单验证正常 - 密码强度、格式校验有效
|
||||
@@ -1,267 +0,0 @@
|
||||
# 数据统计页面重新设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
重新设计数据统计页面,使其与项目现有的深色赛博朋克/终端风格保持一致。采用单页垂直滚动布局,集成迷你图表,提供清晰的数据可视化。
|
||||
|
||||
## 2. 设计语言
|
||||
|
||||
### 视觉风格
|
||||
- **主题**:深色赛博朋克 + 终端美学
|
||||
- **背景**:`var(--bg-void)` 深空黑
|
||||
- **强调色**:青色 `#00f5d4` (现有变量 `var(--accent-cyan)`)
|
||||
- **辅助色**:紫色 `#a855f7` (用于知识库等模块)
|
||||
- **卡片背景**:`rgba(13,21,37,0.8)` 半透明深蓝
|
||||
- **边框**:`1px solid var(--border-dim)`,hover时发光
|
||||
|
||||
### 字体
|
||||
- **数字**:等宽字体 `var(--font-mono)`,大号加粗,带发光效果
|
||||
- **标签**:`var(--font-display)`,9-10px,字母间距 0.15em
|
||||
- **正文**:`var(--font-mono)`,12-13px
|
||||
|
||||
### 动效
|
||||
- 卡片 hover:边框发光 + 微弱上浮
|
||||
- 数字:首次加载时淡入
|
||||
- 图表:绘制动画 300ms
|
||||
|
||||
## 3. 页面结构
|
||||
|
||||
### 单页垂直滚动布局(无 Tabs)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ // DATA METRICS [页面标题] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [SYSTEM HEALTH] 系统健康模块 │
|
||||
│ [CONVERSATIONS] 对话统计模块 │
|
||||
│ [KNOWLEDGE] 知识库模块 │
|
||||
│ [KANBAN] 看板模块 │
|
||||
│ [COMMUNITY] 社区模块 │
|
||||
│ [INSIGHTS] 个人洞察模块 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 模块详细设计
|
||||
|
||||
### 4.1 系统健康 (SYSTEM HEALTH)
|
||||
|
||||
**位置**:页面最顶部,无需认证即可访问
|
||||
|
||||
**卡片布局**:4列网格
|
||||
|
||||
**指标卡片**:
|
||||
| 指标 | 图标 | 格式 |
|
||||
|------|------|------|
|
||||
| CPU 使用率 | Cpu | 45% + 7天迷你柱状图 |
|
||||
| 内存占用 | MemoryStick | 62% + 7天迷你柱状图 |
|
||||
| 磁盘使用 | HardDrive | 38% + 7天迷你柱状图 |
|
||||
| 运行时间 | Clock | 7d 3h 20m |
|
||||
|
||||
**卡片样式**:
|
||||
- 尺寸:自适应,最小 160px
|
||||
- 数字大小:24px,等宽加粗
|
||||
- 趋势图:高度 24px,7个数据点
|
||||
- 标签:9px,letter-spacing 0.15em
|
||||
|
||||
### 4.2 对话统计 (CONVERSATIONS)
|
||||
|
||||
**需要认证**
|
||||
|
||||
**顶部汇总**:横排4个数字卡片
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 总对话数 | 1,234 |
|
||||
| 总消息数 | 5,678 |
|
||||
| Input Tokens | 12.5M |
|
||||
| Output Tokens | 45.2M |
|
||||
|
||||
**图表**:30天趋势迷你折线图
|
||||
- 4条线:对话数、消息数、Input Token、Output Token
|
||||
- 图例在图表下方
|
||||
- 图表高度:120px
|
||||
- 颜色使用主题色
|
||||
|
||||
### 4.3 知识库 (KNOWLEDGE)
|
||||
|
||||
**需要认证**
|
||||
|
||||
**顶部汇总**:
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 新建标签 | 156 |
|
||||
| 文档数 | 89 |
|
||||
| 标签关系 | 423 |
|
||||
|
||||
**图表**:30天趋势迷你折线图
|
||||
- 3条线:新建标签、文档、标签关系
|
||||
- 使用紫色系 `var(--accent-purple)`
|
||||
|
||||
### 4.4 看板 (KANBAN)
|
||||
|
||||
**需要认证**
|
||||
|
||||
**顶部汇总**:
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 待办任务 | 12 |
|
||||
| 新建任务 | 45 (30天) |
|
||||
| 已完成任务 | 38 (30天) |
|
||||
|
||||
**图表**:30天对比柱状图
|
||||
- 两组柱:新建任务 vs 已完成任务
|
||||
- 使用青色和绿色对比
|
||||
|
||||
### 4.5 社区 (COMMUNITY)
|
||||
|
||||
**需要认证**
|
||||
|
||||
**顶部汇总**:
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 发帖数 | 23 |
|
||||
| 回复数 | 156 |
|
||||
| AI 执行 | 12 |
|
||||
|
||||
**图表**:30天趋势迷你折线图
|
||||
- 3条线:发帖、回复、AI执行
|
||||
|
||||
### 4.6 个人洞察 (INSIGHTS)
|
||||
|
||||
**需要认证**
|
||||
|
||||
**布局**:2列
|
||||
|
||||
**左侧 - 活跃时段**:
|
||||
- 24小时柱状图
|
||||
- 显示高峰时段标记
|
||||
|
||||
**右侧 - Top 标签**:
|
||||
- 列表形式显示前5个常用标签
|
||||
- 显示使用次数
|
||||
|
||||
**Token趋势**:
|
||||
- 本月 vs 上月对比
|
||||
- 百分比变化(带颜色指示上升/下降)
|
||||
|
||||
## 5. 组件清单
|
||||
|
||||
### MetricCard 指标卡片
|
||||
```
|
||||
Props:
|
||||
- icon: Component (lucide图标)
|
||||
- label: string
|
||||
- value: string | number
|
||||
- trend?: number[] (可选,迷你图数据)
|
||||
- accentColor?: string (默认cyan)
|
||||
|
||||
States:
|
||||
- default: 静态显示
|
||||
- hover: 边框发光,轻微上浮
|
||||
- loading: 骨架屏
|
||||
- error: 显示 "--" 和错误图标
|
||||
```
|
||||
|
||||
### MiniLineChart 迷你折线图
|
||||
```
|
||||
Props:
|
||||
- data: { date: string, value: number }[]
|
||||
- color?: string
|
||||
- height?: number (默认40px)
|
||||
|
||||
Features:
|
||||
- 纯CSS实现或tiny echarts
|
||||
- 无坐标轴,仅保留趋势
|
||||
- 数据点过多时自动采样
|
||||
```
|
||||
|
||||
### MiniBarChart 迷你柱状图
|
||||
```
|
||||
Props:
|
||||
- data: number[]
|
||||
- color?: string
|
||||
- height?: number (默认24px)
|
||||
- maxBars?: number (默认7)
|
||||
```
|
||||
|
||||
### SectionHeader 区块标题
|
||||
```
|
||||
Props:
|
||||
- title: string
|
||||
- tag?: 'cyan' | 'purple' | 'amber' (标签颜色)
|
||||
|
||||
Style:
|
||||
- 格式:// SECTION_NAME
|
||||
- 左侧竖线装饰
|
||||
- 标签 Chip 在右侧
|
||||
```
|
||||
|
||||
### SummaryRow 汇总行
|
||||
```
|
||||
Props:
|
||||
- items: { label: string, value: string | number }[]
|
||||
- columns?: number (默认4)
|
||||
```
|
||||
|
||||
## 6. 技术实现
|
||||
|
||||
### 前端
|
||||
- **框架**:Vue 3 + TypeScript (已有)
|
||||
- **图表库**:使用 CSS 实现迷你图,或 echarts (已有)
|
||||
- **图标**:lucide-vue-next (已有)
|
||||
- **状态管理**:Pinia (已有)
|
||||
- **API**:StatsView 中已有 stats API 调用
|
||||
|
||||
### 后端
|
||||
- 复用现有 `app/routers/stats.py` 和 `app/services/stats_service.py`
|
||||
- 确保所有接口正确返回数据
|
||||
|
||||
### 样式
|
||||
- 复用 `ChatView.vue` 中的设计变量和样式模式
|
||||
- 使用 CSS Grid 实现响应式布局
|
||||
- 变量:`--bg-panel`, `--accent-cyan`, `--border-dim`, `--font-mono` 等
|
||||
|
||||
## 7. 响应式断点
|
||||
|
||||
| 设备 | 列数 |
|
||||
|------|------|
|
||||
| >= 1200px | 4列 |
|
||||
| 768px - 1199px | 2列 |
|
||||
| < 768px | 1列 |
|
||||
|
||||
## 8. 错误与空状态
|
||||
|
||||
### Error State
|
||||
- 显示错误图标和文字
|
||||
- 提供刷新按钮
|
||||
- 保持页面结构完整
|
||||
|
||||
### Empty State
|
||||
- 各模块独立空状态
|
||||
- 不阻塞其他模块显示
|
||||
- 友好提示文案
|
||||
|
||||
### Loading State
|
||||
- 骨架屏动画
|
||||
- 与卡片结构一致
|
||||
|
||||
## 9. 访问控制
|
||||
|
||||
| 模块 | 认证要求 | 说明 |
|
||||
|------|----------|------|
|
||||
| 系统健康 | 否 | 所有人可看 |
|
||||
| 对话统计 | 是 | 需登录 |
|
||||
| 知识库 | 是 | 需登录 |
|
||||
| 看板 | 是 | 需登录 |
|
||||
| 社区 | 是 | 需登录 |
|
||||
| 个人洞察 | 是 | 需登录 |
|
||||
|
||||
未登录用户访问需认证模块时:
|
||||
- 显示占位卡片结构
|
||||
- 提示"请先登录"
|
||||
- 不发送无效请求
|
||||
|
||||
## 10. 数据刷新
|
||||
|
||||
- 页面进入时加载所有数据
|
||||
- 支持手动刷新按钮(每个模块独立刷新)
|
||||
- 数字变化时无动画(避免干扰)
|
||||
@@ -1,473 +0,0 @@
|
||||
# 交互广场重新设计
|
||||
|
||||
## 1. 概述与目标
|
||||
|
||||
将现有的论坛(交互广场)从传统的帖子/回复模式,重构为三个AI驱动的智能板块:
|
||||
1. **AI学习板块** - 模型分析用户活动,学习客观知识并加入知识图谱,向用户汇报学习成果
|
||||
2. **AI建议板块** - 基于用户习惯和数据,提供个性化建议
|
||||
3. **AI交互板块** - 用户发起学习主题,或AI主动探索补充知识
|
||||
|
||||
## 2. 设计风格
|
||||
|
||||
沿用项目现有的深色赛博朋克/终端风格:
|
||||
- 背景:`var(--bg-void)` 深空黑
|
||||
- 强调色:紫色 `#a855f7` (用于交互广场专属色调)
|
||||
- 卡片背景:`var(--bg-card)`
|
||||
- 边框:`1px solid var(--border-dim)`,hover时发光
|
||||
- 字体:等宽字体 `var(--font-mono)`,标题用 `var(--font-display)`
|
||||
|
||||
## 3. 页面结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ // INTERACTIVE PLAZA [页面标题] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [MODEL LEARNING] AI学习板块 │ │
|
||||
│ │ AI分析你的活动,学习知识并汇报 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 今日学习摘要 │ │
|
||||
│ │ • 学习历史时间线 │ │
|
||||
│ │ • 知识图谱更新统计 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [SUGGESTIONS] AI建议板块 │ │
|
||||
│ │ 基于你的习惯提供个性化建议 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 知识补充建议 │ │
|
||||
│ │ • 效率优化建议 │ │
|
||||
│ │ • 技能深耕建议 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [INTERACTIVE] AI交互学习板块 │ │
|
||||
│ │ 用户发起学习主题,AI主动探索 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ • 用户发起的学习主题 │ │
|
||||
│ │ • AI主动学习的内容 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 功能详情
|
||||
|
||||
### 4.1 AI学习板块 (MODEL LEARNING)
|
||||
|
||||
**数据来源:**
|
||||
- 对话记录(`messages` 表,Message模型)- 分析对话内容提取概念
|
||||
- 看板任务(`tasks` 表,Task模型)- 识别技术栈和工作流程
|
||||
- 知识库(`documents`, `kg_nodes` 表)- 补充知识缺口
|
||||
|
||||
**学习流程:**
|
||||
```
|
||||
定时任务触发 → 分析近期活动 → 提取概念/术语/事实
|
||||
→ 存入知识图谱(KGNode) → 生成学习报告 → 存入learning_records表
|
||||
```
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 learning_records 表
|
||||
# 继承 app.models.base.BaseModel,自动获得 id, created_at, updated_at
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class LearningRecord(BaseModel):
|
||||
__tablename__ = "learning_records"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
learning_type = Column(String(50), nullable=False) # concept, technology, workflow
|
||||
topic = Column(String(500), nullable=False) # 学习主题
|
||||
summary = Column(Text, nullable=False) # AI生成的学习摘要
|
||||
source = Column(String(50), nullable=False) # conversation, kanban, knowledge
|
||||
source_ids = Column(JSON, nullable=True) # 来源ID列表,如 {conversation_ids: [...], task_ids: [...]}
|
||||
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
||||
```
|
||||
|
||||
**KGNode实体类型扩展:**
|
||||
- `learned_concept` - 从对话中学到的概念
|
||||
- `technology` - 识别出的技术栈
|
||||
- `workflow` - 从看板任务中提取的工作流程
|
||||
|
||||
**前端展示:**
|
||||
|
||||
1. **今日学习摘要卡片**
|
||||
- AI生成的自然语言总结
|
||||
- 示例:"今日学习了依赖注入和异步编程两个概念,它们都来自你关于FastAPI的讨论"
|
||||
- 显示来源标签:对话/看板/知识库
|
||||
|
||||
2. **学习历史时间线**
|
||||
- 垂直时间线布局
|
||||
- 每条记录显示:时间、主题、摘要
|
||||
- 点击展开查看详情
|
||||
|
||||
3. **知识图谱更新统计**
|
||||
- 今日新增节点数
|
||||
- 今日新建关系数
|
||||
- 迷你柱状图显示各类别占比(可复用 MiniBarChart)
|
||||
|
||||
### 4.2 AI建议板块 (SUGGESTIONS)
|
||||
|
||||
**建议类型:**
|
||||
|
||||
1. **知识补充建议 (knowledge)**
|
||||
- 检测知识图谱薄弱领域
|
||||
- 基于用户提问推断知识缺口
|
||||
- 示例:"你的知识图谱在'微服务架构'领域较为薄弱,建议深入学习"
|
||||
|
||||
2. **效率优化建议 (efficiency)**
|
||||
- 分析用户使用模式
|
||||
- 推荐最佳实践
|
||||
- 示例:"你通常在下午工作效率最高,建议将复杂任务安排在这个时段"
|
||||
|
||||
3. **技能深耕建议 (skill)**
|
||||
- 基于高频话题
|
||||
- 推荐深入学习方向
|
||||
- 示例:"你最近频繁讨论API设计,建议学习REST最佳实践和GraphQL"
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 suggestions 表
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Suggestion(BaseModel):
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
suggestion_type = Column(String(50), nullable=False) # knowledge, efficiency, skill
|
||||
title = Column(String(500), nullable=False) # 建议标题
|
||||
content = Column(Text, nullable=False) # 建议内容
|
||||
source_data = Column(JSON, nullable=True) # 分析依据,如 {knowledge_gaps: [...], usage_patterns: {...}}
|
||||
is_read = Column(Boolean, default=False) # 是否已读
|
||||
is_dismissed = Column(Boolean, default=False) # 是否忽略
|
||||
```
|
||||
|
||||
**前端展示:**
|
||||
- 卡片列表布局
|
||||
- 每个建议显示:图标、类型标签、标题、内容
|
||||
- 右侧显示建议来源分析
|
||||
- 提供"查看详情"和"忽略"按钮
|
||||
|
||||
### 4.3 AI交互板块 (INTERACTIVE)
|
||||
|
||||
**用户发起学习:**
|
||||
|
||||
1. 用户输入想学习的主题
|
||||
2. AI分析主题,搜索知识库
|
||||
3. 如有需要,AI主动抓取外部资源
|
||||
4. 生成学习报告
|
||||
5. 自动存入知识图谱
|
||||
6. 在交互板块展示
|
||||
|
||||
**数据库扩展:**
|
||||
|
||||
```python
|
||||
# 新增 interactive_topics 表
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class InteractiveTopic(BaseModel):
|
||||
__tablename__ = "interactive_topics"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
topic = Column(String(500), nullable=False) # 学习主题
|
||||
status = Column(String(50), nullable=False) # pending, learning, completed, failed
|
||||
result = Column(Text, nullable=True) # 学习结果/报告
|
||||
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
||||
source = Column(String(50), nullable=False) # user_initiated, ai_proactive
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
```
|
||||
|
||||
**AI主动学习:**
|
||||
|
||||
1. AI分析用户历史提问
|
||||
2. 发现知识缺口或关联主题
|
||||
3. 主动学习并生成报告
|
||||
4. 在交互板块标记为"AI主动"
|
||||
|
||||
**前端展示:**
|
||||
- 两个子区块:用户发起 / AI主动
|
||||
- 输入框:"让AI学习 [主题]"
|
||||
- 正在进行的学习任务显示进度
|
||||
- 已完成的学习显示结果摘要
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 后端接口
|
||||
|
||||
```
|
||||
GET /api/forum/learning/summary
|
||||
- 获取今日学习摘要
|
||||
- 返回: { summary, records[], stats{ nodes_created, edges_created } }
|
||||
|
||||
GET /api/forum/learning/history?page=1&limit=20
|
||||
- 获取学习历史
|
||||
- 返回: { records[], total }
|
||||
|
||||
GET /api/forum/suggestions
|
||||
- 获取所有建议
|
||||
- 返回: { suggestions[] }
|
||||
|
||||
GET /api/forum/suggestions/{id}
|
||||
- 获取单个建议详情
|
||||
- 返回: Suggestion
|
||||
|
||||
PATCH /api/forum/suggestions/{id}/read
|
||||
- 标记建议为已读
|
||||
|
||||
DELETE /api/forum/suggestions/{id}/dismiss
|
||||
- 忽略/删除建议
|
||||
|
||||
GET /api/forum/interactive/topics
|
||||
- 获取交互主题列表
|
||||
- 返回: { user_initiated[], ai_proactive[] }
|
||||
|
||||
POST /api/forum/interactive/learn
|
||||
- 用户发起学习
|
||||
- Body: { topic: string }
|
||||
- 返回: { topic_id, status }
|
||||
|
||||
GET /api/forum/interactive/topics/{id}
|
||||
- 获取学习主题详情/结果
|
||||
```
|
||||
|
||||
### 5.2 前端API
|
||||
|
||||
```typescript
|
||||
// TypeScript 类型定义
|
||||
interface LearningSummary {
|
||||
summary: string
|
||||
records: LearningRecord[]
|
||||
stats: {
|
||||
nodes_created: number
|
||||
edges_created: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LearningRecord {
|
||||
id: string
|
||||
learning_type: 'concept' | 'technology' | 'workflow'
|
||||
topic: string
|
||||
summary: string
|
||||
source: string
|
||||
source_ids?: { conversation_ids?: string[]; task_ids?: string[] }
|
||||
kg_nodes_created?: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
id: string
|
||||
suggestion_type: 'knowledge' | 'efficiency' | 'skill'
|
||||
title: string
|
||||
content: string
|
||||
source_data?: Record<string, any>
|
||||
is_read: boolean
|
||||
is_dismissed: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface InteractiveTopic {
|
||||
id: string
|
||||
topic: string
|
||||
status: 'pending' | 'learning' | 'completed' | 'failed'
|
||||
result?: string
|
||||
kg_nodes_created?: string[]
|
||||
source: 'user_initiated' | 'ai_proactive'
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
// API 方法
|
||||
const forumApi = {
|
||||
// learning
|
||||
fetchLearningSummary(): Promise<LearningSummary>,
|
||||
fetchLearningHistory(params: { page: number, limit: number }): Promise<{ records: LearningRecord[], total: number }>,
|
||||
|
||||
// suggestions
|
||||
fetchSuggestions(): Promise<Suggestion[]>,
|
||||
getSuggestion(id: string): Promise<Suggestion>,
|
||||
markSuggestionRead(id: string): Promise<void>,
|
||||
dismissSuggestion(id: string): Promise<void>,
|
||||
|
||||
// interactive
|
||||
fetchInteractiveTopics(): Promise<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>,
|
||||
initiateLearning(topic: string): Promise<InteractiveTopic>,
|
||||
getTopicDetail(id: string): Promise<InteractiveTopic>,
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 组件结构
|
||||
|
||||
```
|
||||
frontend/src/views/ForumView.vue # 主页面,三板块布局
|
||||
frontend/src/components/forum/
|
||||
├── LearningSection.vue # AI学习板块
|
||||
│ ├── LearningSummaryCard.vue # 今日摘要卡片
|
||||
│ ├── LearningTimeline.vue # 学习历史时间线
|
||||
│ └── LearningStats.vue # 图谱更新统计(复用MiniBarChart)
|
||||
├── SuggestionSection.vue # AI建议板块
|
||||
│ ├── SuggestionCard.vue # 建议卡片
|
||||
│ └── SuggestionList.vue # 建议列表
|
||||
└── InteractiveSection.vue # AI交互板块
|
||||
├── LearningInput.vue # 学习主题输入框
|
||||
├── UserInitiatedList.vue # 用户发起列表
|
||||
└── AIProactiveList.vue # AI主动列表
|
||||
|
||||
# 新增通用组件
|
||||
frontend/src/components/forum/MiniDonutChart.vue # 环形图(用于知识类别占比)
|
||||
```
|
||||
|
||||
## 7. 服务层
|
||||
|
||||
### 7.1 LearningService
|
||||
|
||||
```python
|
||||
from app.core.llm import get_llm_client
|
||||
|
||||
class LearningService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def generate_daily_summary(user_id: str) -> str:
|
||||
"""分析用户今日活动,生成学习摘要"""
|
||||
# 使用 LLM 分析提取的概念,生成自然语言摘要
|
||||
concepts = await self.extract_concepts(...)
|
||||
prompt = f"根据以下学习内容生成简短摘要:{concepts}"
|
||||
return await self.llm.chat(prompt)
|
||||
|
||||
async def extract_concepts_from_conversations(user_id: str, since: datetime) -> list[dict]:
|
||||
"""从对话中提取概念"""
|
||||
|
||||
async def identify_technologies_from_kanban(user_id: str) -> list[dict]:
|
||||
"""从看板任务中识别技术栈"""
|
||||
|
||||
async def create_kg_nodes(user_id: str, learnings: list[dict]) -> list[str]:
|
||||
"""创建知识图谱节点"""
|
||||
|
||||
async def record_learning(...) -> LearningRecord:
|
||||
"""记录学习成果"""
|
||||
```
|
||||
|
||||
### 7.2 SuggestionService
|
||||
|
||||
```python
|
||||
class SuggestionService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def generate_suggestions(user_id: str) -> list[Suggestion]:
|
||||
"""生成个性化建议"""
|
||||
# 分析知识缺口、使用模式、技能机会
|
||||
gaps = await self.analyze_knowledge_gaps(user_id)
|
||||
patterns = await self.analyze_usage_patterns(user_id)
|
||||
skills = await self.analyze_skill_opportunities(user_id)
|
||||
|
||||
# 使用 LLM 生成建议
|
||||
prompt = f"基于以下分析生成建议:知识缺口{gaps},使用模式{patterns},技能机会{skills}"
|
||||
return await self.llm.chat(prompt)
|
||||
|
||||
async def analyze_knowledge_gaps(user_id: str) -> list[dict]:
|
||||
"""分析知识图谱缺口"""
|
||||
|
||||
async def analyze_usage_patterns(user_id: str) -> dict:
|
||||
"""分析使用模式"""
|
||||
|
||||
async def identify_skill_opportunities(user_id: str) -> list[dict]:
|
||||
"""识别技能提升机会"""
|
||||
```
|
||||
|
||||
### 7.3 InteractiveService
|
||||
|
||||
```python
|
||||
class InteractiveService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.llm = get_llm_client()
|
||||
|
||||
async def initiate_learning(user_id: str, topic: str) -> InteractiveTopic:
|
||||
"""用户发起学习"""
|
||||
|
||||
async def execute_learning(topic_id: str) -> dict:
|
||||
"""执行学习任务:
|
||||
1. 搜索知识库相关节点
|
||||
2. 使用 LLM 深入学习主题
|
||||
3. 生成学习报告
|
||||
4. 创建 KGNode
|
||||
5. 更新 topic 状态
|
||||
"""
|
||||
topic = await self.get_topic(topic_id)
|
||||
content = await self.research_topic(topic.topic)
|
||||
report = await self.generate_learning_report(topic, content)
|
||||
await self.create_kg_nodes_from_report(report)
|
||||
await self.update_topic_status(topic_id, 'completed', report)
|
||||
|
||||
async def generate_learning_report(self, topic: InteractiveTopic, content: str) -> str:
|
||||
"""使用 LLM 生成结构化学习报告"""
|
||||
```
|
||||
|
||||
## 8. 定时任务
|
||||
|
||||
每日凌晨生成学习报告:
|
||||
- 分析昨日用户活动
|
||||
- 提取新概念和技术栈
|
||||
- 更新知识图谱
|
||||
- 生成学习摘要存入数据库
|
||||
|
||||
**集成方式:** 使用项目现有的 `scheduler_service.py`
|
||||
|
||||
```python
|
||||
# 在 scheduler_service.py 的 start_scheduler() 中添加
|
||||
from app.services.learning_service import LearningService
|
||||
|
||||
async def daily_learning_job():
|
||||
"""每日凌晨0:30生成学习报告"""
|
||||
from app.database import get_db_session
|
||||
|
||||
async for db in get_db_session():
|
||||
service = LearningService(db)
|
||||
users = await get_all_active_users(db)
|
||||
for user in users:
|
||||
await service.generate_and_record_daily_learning(user.id)
|
||||
break
|
||||
|
||||
# 在 start_scheduler() 中注册
|
||||
scheduler.add_job(daily_learning_job, "cron", hour=0, minute=30, id="daily_learning")
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 无活动数据 | 显示"今日暂无学习成果",不生成空记录 |
|
||||
| 知识图谱更新失败 | 回滚学习记录,标记为失败状态 |
|
||||
| AI生成失败 | 记录原始数据,标记需要重试 |
|
||||
| 用户发起学习主题为空 | 前端验证拦截,不发送请求 |
|
||||
|
||||
## 10. 访问控制
|
||||
|
||||
所有板块需要用户登录后访问:
|
||||
- 未登录用户显示"请先登录"提示
|
||||
- 不发送无效API请求
|
||||
- 保持页面结构完整
|
||||
|
||||
## 11. 技术实现
|
||||
|
||||
**前端:**
|
||||
- Vue 3 + TypeScript
|
||||
- 复用现有组件样式(StatsView.vue模式)
|
||||
- CSS实现迷你图表
|
||||
- lucide-vue-next图标
|
||||
|
||||
**后端:**
|
||||
- FastAPI + SQLAlchemy
|
||||
- 复用现有数据库连接
|
||||
- 新增三个Service类
|
||||
- 复用现有认证机制
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
用户活动 → LearningService分析 → KGNode创建 → LearningRecord存储
|
||||
↓
|
||||
AI生成摘要 → 前端展示
|
||||
```
|
||||
@@ -1,307 +0,0 @@
|
||||
# 知识库文件夹分层设计
|
||||
|
||||
> **Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD 操作,支持知识大脑汇聚各类内容。
|
||||
|
||||
## 1. 概念与愿景
|
||||
|
||||
知识库是用户的**资料中枢**,文件夹分层让知识更有序。用户可以按主题/项目/类型建立文件夹层级,如 `技术文档/Python/入门.pdf`。
|
||||
|
||||
知识大脑会汇聚来自知识库、待办、看板、论坛、对话的内容,形成完整的用户知识画像。文件夹是知识的入口分类,而非知识图谱的一部分。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 Folder 表(邻接表模式)
|
||||
|
||||
```python
|
||||
class Folder(BaseModel):
|
||||
__tablename__ = "folders"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # NULL=根目录
|
||||
# 注意: id, created_at, updated_at 继承自 BaseModel
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- 邻接表模式:通过 `parent_id` 指向父文件夹
|
||||
- 根目录文件夹的 `parent_id = NULL`
|
||||
- 查询完整树结构使用递归 CTE
|
||||
- **唯一约束**:`user_id + parent_id + name` 组合唯一,防止同级重名
|
||||
|
||||
**验证规则:**
|
||||
- 文件夹名称不能为空,最大 255 字符
|
||||
- 不允许包含字符:`/ \ * ? :`
|
||||
- 最大嵌套深度:10 层(防止 UI/性能问题)
|
||||
|
||||
### 2.2 Document 表变更
|
||||
|
||||
```python
|
||||
class Document(BaseModel):
|
||||
# ...现有字段...
|
||||
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- `folder_id = NULL` 表示文档在根目录(未分类)
|
||||
- 删除文件夹时,级联删除该文件夹及其所有子文件夹中的文档
|
||||
|
||||
### 2.3 ChromaDB Metadata
|
||||
|
||||
```python
|
||||
{
|
||||
"document_id": "xxx",
|
||||
"document_title": "入门.pdf",
|
||||
"folder_path": "/技术文档/Python", # 完整路径,用于检索过滤
|
||||
"file_type": "pdf",
|
||||
"chunk_index": 0,
|
||||
}
|
||||
```
|
||||
|
||||
## 3. API 接口
|
||||
|
||||
### 3.1 文件夹管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/folders` | 获取用户的完整文件夹树 |
|
||||
| POST | `/api/folders` | 创建文件夹 `{ name, parent_id? }` |
|
||||
| PUT | `/api/folders/{id}` | 重命名文件夹 `{ name }` |
|
||||
| DELETE | `/api/folders/{id}` | 删除文件夹(级联删除文档) |
|
||||
|
||||
**GET /api/folders 响应:**
|
||||
```json
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"id": "xxx",
|
||||
"name": "技术文档",
|
||||
"parent_id": null,
|
||||
"children": [
|
||||
{
|
||||
"id": "yyy",
|
||||
"name": "Python",
|
||||
"parent_id": "xxx",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 文档管理变更
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/documents?folder_id=` | 按文件夹查询文档 |
|
||||
| POST | `/api/documents` | 上传文档时指定 `folder_id` |
|
||||
| DELETE | `/api/documents/{id}` | 删除文档 |
|
||||
|
||||
**POST /api/documents 请求增加可选字段:**
|
||||
```json
|
||||
{
|
||||
"file": "<binary>",
|
||||
"folder_id": "yyy" // 可选,不传表示根目录
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 安全与权限
|
||||
|
||||
**所有权验证:**
|
||||
- 所有文件夹操作必须验证 `folder.user_id == current_user.id`
|
||||
- 文档操作时验证 `document.user_id == current_user.id`
|
||||
- `folder_id` 参数需要验证归属,防止跨用户访问
|
||||
|
||||
**示例中间件:**
|
||||
```python
|
||||
async def verify_folder_access(folder_id: str, user_id: str, db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权访问此文件夹")
|
||||
```
|
||||
|
||||
### 3.4 向量检索变更
|
||||
|
||||
`KnowledgeService.retrieve()` 增加可选参数 `folder_id`:
|
||||
|
||||
```python
|
||||
async def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
folder_id: str | None = None, # 新增
|
||||
top_k: int = 5,
|
||||
):
|
||||
# 如果指定 folder_id,构建 path 前缀过滤
|
||||
folder_path = await self._get_folder_path(folder_id)
|
||||
where = {"folder_path": {"$starts_with": folder_path}} if folder_path else None
|
||||
```
|
||||
|
||||
### 3.5 ChromaDB 同步策略
|
||||
|
||||
**文件夹重命名/移动时的同步:**
|
||||
|
||||
由于 ChromaDB metadata 中存储了 `folder_path`,当文件夹路径变化时需要同步更新:
|
||||
|
||||
```python
|
||||
async def update_folder_paths(folder_id: str, old_path: str, new_path: str):
|
||||
"""更新所有子文件夹和文档的路径"""
|
||||
# 1. 更新所有子文件夹的 path
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == folder_id)
|
||||
)
|
||||
for child in children.scalars():
|
||||
child_new_path = new_path + "/" + child.name
|
||||
await update_folder_paths(child.id, old_path + "/" + child.name, child_new_path)
|
||||
|
||||
# 2. 更新该文件夹下所有文档的 ChromaDB metadata
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == folder_id)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
collection.update(
|
||||
where={"document_id": doc.id},
|
||||
set={"folder_path": new_path}
|
||||
)
|
||||
```
|
||||
|
||||
**删除文件夹时的清理:**
|
||||
|
||||
```python
|
||||
async def delete_folder_cascade(folder_id: str):
|
||||
"""级联删除:先删子文件夹,再删文档,最后删自己"""
|
||||
# 1. 递归删除所有子文件夹
|
||||
children = await db.execute(
|
||||
select(Folder).where(Folder.parent_id == folder_id)
|
||||
)
|
||||
for child in children.scalars():
|
||||
await delete_folder_cascade(child.id)
|
||||
|
||||
# 2. 删除该文件夹下所有文档(从 ChromaDB 和数据库)
|
||||
docs = await db.execute(
|
||||
select(Document).where(Document.folder_id == folder_id)
|
||||
)
|
||||
for doc in docs.scalars():
|
||||
await knowledge_service.delete_from_vectorstore(user_id, doc.id)
|
||||
await db.delete(doc)
|
||||
|
||||
# 3. 删除文件夹本身
|
||||
folder = await db.get(Folder, folder_id)
|
||||
await db.delete(folder)
|
||||
```
|
||||
|
||||
## 4. 前端设计
|
||||
|
||||
### 4.1 布局结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ KNOWLEDGE BASE [+新建文件夹] [+上传] │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📁 技术文档 │ 搜索栏 [🔍 搜索...] [混合▼] │
|
||||
│ 📁 Python │ │
|
||||
│ 📄 入门 │ ┌─────────────────────────────────┐ │
|
||||
│ 📄 进阶 │ │ 文档标题 │ │
|
||||
│ 📁 Vue │ │ 类型 · 大小 · 状态 │ │
|
||||
│ 📁 产品 │ └─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ 📁 临时文件 │ ┌─────────────────────────────────┐ │
|
||||
│ │ │ ... │ │
|
||||
│ │ └─────────────────────────────────┘ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 组件结构
|
||||
|
||||
```
|
||||
KnowledgeView
|
||||
├── Header (标题 + 操作按钮)
|
||||
├── MainLayout (flexbox: sidebar + content)
|
||||
│ ├── FolderTree (左侧边栏)
|
||||
│ │ ├── FolderItem (递归组件)
|
||||
│ │ │ ├── folder icon + name
|
||||
│ │ │ ├── children (递归)
|
||||
│ │ │ └── context menu (右键: 重命名/删除)
|
||||
│ │ └── AddFolderButton
|
||||
│ │
|
||||
│ └── ContentArea (右侧主区域)
|
||||
│ ├── SearchBar
|
||||
│ ├── UploadZone
|
||||
│ ├── DocumentList
|
||||
│ └── SearchResults
|
||||
```
|
||||
|
||||
### 4.3 交互细节
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 点击文件夹 | 高亮选中,显示该文件夹下文档 |
|
||||
| 右键文件夹 | 弹出菜单:重命名 / 删除 |
|
||||
| 双击文件夹名 | 进入编辑状态 |
|
||||
| 新建文件夹 | 弹出输入框,默认在当前选中位置创建 |
|
||||
| 上传文档 | 需先选择目标文件夹,否则默认根目录 |
|
||||
| 搜索 | 可选限定在当前文件夹内搜索 |
|
||||
|
||||
### 4.4 UI 风格
|
||||
|
||||
保持一致的 sci-fi holographic 风格:
|
||||
- 主色调:青色 `#00f5d4` + 深色背景
|
||||
- 文件夹图标:使用 Folder/FolderOpen 图标
|
||||
- 悬停/选中状态:边框高亮 + 背景色变化
|
||||
- 动画:展开/折叠动画 200ms ease
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
### Phase 1: 数据层
|
||||
1. 创建 `Folder` 模型和表
|
||||
2. 修改 `Document` 模型,增加 `folder_id` 外键
|
||||
3. 添加数据库迁移
|
||||
|
||||
### Phase 2: 后端 API
|
||||
1. 实现文件夹 CRUD 接口
|
||||
2. 修改文档上传接口,支持 `folder_id`
|
||||
3. 修改文档列表接口,支持 `folder_id` 过滤
|
||||
4. 修改向量检索,支持 `folder_id` 范围限定
|
||||
5. 实现递归 CTE 查询文件夹树
|
||||
|
||||
**递归 CTE 示例(获取完整文件夹树):**
|
||||
```sql
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
-- 基础查询:根文件夹
|
||||
SELECT id, name, parent_id, 0 as depth
|
||||
FROM folders
|
||||
WHERE user_id = :user_id AND parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 递归查询:子文件夹
|
||||
SELECT f.id, f.name, f.parent_id, ft.depth + 1
|
||||
FROM folders f
|
||||
INNER JOIN folder_tree ft ON ft.id = f.parent_id
|
||||
WHERE f.user_id = :user_id
|
||||
)
|
||||
SELECT * FROM folder_tree ORDER BY depth, name;
|
||||
```
|
||||
|
||||
### Phase 3: 前端
|
||||
1. 创建 `FolderTree` 组件
|
||||
2. 改造 `KnowledgeView` 布局
|
||||
3. 实现文件夹右键菜单(重命名/删除)
|
||||
4. 实现新建文件夹弹窗
|
||||
5. 上传时强制选择文件夹
|
||||
|
||||
### Phase 4: 测试
|
||||
1. 文件夹 CRUD 测试
|
||||
2. 级联删除测试(删除文件夹 + 文档)
|
||||
3. 向量检索按文件夹过滤测试
|
||||
4. 前端交互测试
|
||||
|
||||
## 6. 技术约束
|
||||
|
||||
- SQLite 的递归 CTE 查询文件夹树
|
||||
- 删除文件夹时先删除子文件夹(递归),再删除文档
|
||||
- ChromaDB 的 `where` 过滤使用 `$starts_with` 做路径前缀匹配
|
||||
- 前端递归组件注意防止无限循环
|
||||
@@ -1,157 +0,0 @@
|
||||
# LLM 模型配置表格设计
|
||||
|
||||
## 1. 概述
|
||||
|
||||
重新设计 Settings 页面的 LLM 模型配置 UI,将原有的卡片列表改为表格行内编辑形式,简化交互、减少页面长度,同时支持多模型配置。
|
||||
|
||||
## 2. 需求
|
||||
|
||||
- **chat**: 必填,多个(子智能体可选不同模型)
|
||||
- **vlm**: 选填,多个
|
||||
- **embedding**: 必填,1 个(知识库专用)
|
||||
- **rerank**: 必填,1 个(知识库专用)
|
||||
|
||||
## 3. UI 设计
|
||||
|
||||
### 3.1 整体布局
|
||||
|
||||
每种 LLM 类型(chat/vlm/embedding/rerank)独立成区,区头部显示类型名称和必填/选填标识,右上角有 [+] 添加按钮。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ // LLM CONFIGURATION │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─ CHAT ─────────────────────────────────────────────── [+] │
|
||||
│ │ 名称 │ Provider │ 模型 │ 状态 │ 操作 │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ Agent-Chat │ OpenAI │ gpt-4o │ ● 可用 │ ▶ ✕ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ... (vlm, embedding, rerank 同理) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 表格列(精简版)
|
||||
|
||||
| 列 | 说明 |
|
||||
|----|------|
|
||||
| 名称 | 模型名称,支持输入编辑 |
|
||||
| Provider | 下拉选择:OpenAI / Claude / Ollama / DeepSeek / Custom |
|
||||
| 模型 | 模型名称,支持输入编辑 |
|
||||
| 状态 | ● 可用(绿色)/ ○ 不可用(灰色)/ ⚠ 必填未填(红色) |
|
||||
| 操作 | 展开详情按钮 ▶ / 删除按钮 ✕ |
|
||||
|
||||
### 3.3 行内展开详情面板
|
||||
|
||||
点击任意行,行下方展开详情表单:
|
||||
|
||||
```
|
||||
│ ▼ Agent-Chat │ OpenAI │ gpt-4o │ ● 可用 │ ▶ ✕ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Provider: [OpenAI ▼] Model: [gpt-4o ] │ │
|
||||
│ │ Base URL: [https://api.openai.com/v1 ] │ │
|
||||
│ │ API Key: [sk-•••••••••••••••• ] 👁 │ │
|
||||
│ │ │ │
|
||||
│ │ [▶ 测试连接] [保存] [取消] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
```
|
||||
|
||||
### 3.4 状态说明
|
||||
|
||||
| 状态 | 颜色 | 含义 |
|
||||
|------|------|------|
|
||||
| ● 可用 | 绿色 | 测试通过 |
|
||||
| ○ 不可用 | 灰色 | 未测试或测试失败 |
|
||||
| ⚠ 必填未填 | 红色 | chat/embedding/rerank 未配置 |
|
||||
|
||||
### 3.5 警告提示
|
||||
|
||||
当 chat/embedding/rerank 任一类型为空时,表格顶部显示红色警告条:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ⚠ chat / embedding / rerank 为知识库必填,请确保已配置 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 交互规则
|
||||
|
||||
| 动作 | 行为 |
|
||||
|------|------|
|
||||
| 添加模型 | 点击 [+] 在对应类型底部添加新行,状态默认为 ○ 不可用 |
|
||||
| 展开编辑 | 点击任意行,行内展开详情面板,同时收起其他已展开的行 |
|
||||
| 测试连接 | 点击"测试连接",调用后端 API,测试通过则状态变 ● 可用,失败显示错误 Toast |
|
||||
| 保存 | 只有测试通过的模型才能保存,保存后更新 originalLlmConfig |
|
||||
| 删除 | 点击 ✕ 删除该模型(embedding/rerank 至少保留 1 个) |
|
||||
| 取消编辑 | 点击"取消"或再次点击展开按钮,收起详情面板,表单数据恢复原值 |
|
||||
| Provider 变化 | 自动填充对应 Provider 的默认 Base URL |
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
```typescript
|
||||
interface LLMModelConfig {
|
||||
name: string // 模型名称
|
||||
provider: 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
|
||||
model: string // 模型名称
|
||||
base_url: string // API Base URL
|
||||
api_key: string // API Key
|
||||
enabled: boolean // 是否启用
|
||||
}
|
||||
|
||||
interface LLMConfig {
|
||||
chat: LLMModelConfig[] // 必填,多个
|
||||
vlm: LLMModelConfig[] // 选填,多个
|
||||
embedding: LLMModelConfig[] // 必填,1个
|
||||
rerank: LLMModelConfig[] // 必填,1个
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 后端 API
|
||||
|
||||
### 6.1 保存策略
|
||||
|
||||
`saveModel(type, index)` 发送完整 `LLMConfig` 对象到后端,后端整体替换该类型的模型列表。
|
||||
|
||||
- chat/vlm: 列表直接替换
|
||||
- embedding/rerank: 列表直接替换(限制最多 1 个)
|
||||
|
||||
### 6.2 测试连接 API
|
||||
|
||||
```typescript
|
||||
POST /api/settings/llm/test
|
||||
{
|
||||
"type": "chat" | "vlm" | "embedding" | "rerank",
|
||||
"provider": "openai" | "claude" | "ollama" | "deepseek" | "custom",
|
||||
"model": "gpt-4o",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```typescript
|
||||
{ "success": true, "message": "连接成功" }
|
||||
{ "success": false, "error": "错误信息" }
|
||||
```
|
||||
|
||||
## 7. 组件结构
|
||||
|
||||
```
|
||||
SettingsView.vue
|
||||
├── LLMConfigSection (chat/vlm/embedding/rerank 四区)
|
||||
│ ├── LLMTypeCard (每个类型一个卡片)
|
||||
│ │ ├── LLMTable (表格头部 + 列表)
|
||||
│ │ │ ├── LLMTableRow (每行模型)
|
||||
│ │ │ └── LLMExpandPanel (展开的详情面板)
|
||||
│ │ └── LLMEmptyState (空状态 + 添加按钮)
|
||||
│ └── LLMWarning (必填警告条)
|
||||
```
|
||||
|
||||
## 8. 实现要点
|
||||
|
||||
1. **单行展开**: 点击行时收起其他已展开行,保持 UI 简洁
|
||||
2. **测试通过才可保存**: 保存按钮仅在 `model.enabled === true` 时可用
|
||||
3. **API Key 脱敏**: 列表中不显示 API Key,详情面板中默认隐藏(显示为 ••••)
|
||||
4. **Provider 默认 URL**: `onProviderChange` 自动填充默认值
|
||||
5. **深拷贝比较**: `isModelDirty` 使用 `JSON.stringify` 深拷贝比较
|
||||
6. **originalLlmConfig 同步**: 每次保存成功后更新原始配置副本
|
||||
@@ -1,288 +0,0 @@
|
||||
# Skill 系统设计方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
当前 Jarvis 系统采用基于 LangGraph 的多 Agent 架构(Master/Planner/Executor/Librarian/Analyst),通过关键词规则路由到子 Agent。系统缺乏可扩展的 Skill 机制,无法让 Agent 按需调用自定义能力。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
构建一个 **Skill 系统**,让每个 Agent 能够:
|
||||
- 挂载可配置的 Skill 能力插件
|
||||
- 由 LLM 自主判断何时使用哪个 Skill
|
||||
- 支持私有/团队共享/市场三种可见性
|
||||
- Skill 作为 Agent 的指令模板 + 工具组合
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心理念
|
||||
|
||||
**Skill 是 Agent 的"能力插件",由 LLM 自主决策调用时机。**
|
||||
|
||||
```
|
||||
用户: "帮我规划今天的工作"
|
||||
│
|
||||
▼
|
||||
Master Agent 理解意图
|
||||
│
|
||||
▼
|
||||
路由到 Planner Agent
|
||||
│
|
||||
▼
|
||||
Planner 分析任务 → 自主判断需要什么 Skill
|
||||
│
|
||||
├──→ 需要数据 → 调用 "数据获取" Skill
|
||||
├──→ 需要优先级 → 调用 "任务排序" Skill
|
||||
└──→ 需要时间安排 → 调用 "日程规划" Skill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 Skill 实体
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|-----|
|
||||
| id | UUID | 主键 |
|
||||
| name | str | Skill 名称,如 "任务排序" |
|
||||
| description | str | 供 LLM 理解该 Skill 的用途 |
|
||||
| instructions | str | Agent 执行时的系统指令模板 |
|
||||
| agent_type | AgentRole | 适用于哪个 Agent (master/planner/executor/librarian/analyst) |
|
||||
| tools | List[str] | 引用的工具名称列表 |
|
||||
| required_context | List[str] | 需要的前置数据 |
|
||||
| output_format | str | 输出格式要求(可选) |
|
||||
| visibility | enum | private/team/market |
|
||||
| team_id | UUID | 团队 ID(visibility=team 时使用) |
|
||||
| is_active | bool | 是否启用 |
|
||||
| owner_id | UUID | 创建者 ID |
|
||||
| created_at | datetime | 创建时间 |
|
||||
| updated_at | datetime | 更新时间 |
|
||||
|
||||
### 3.2 Agent-Skill 关联
|
||||
|
||||
每个 Agent 运行时从数据库加载其 `agent_type` 对应的所有 `is_active=True` 的 Skills,作为可选能力供 LLM 调用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统架构
|
||||
|
||||
### 4.1 组件关系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Agent Brain │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Master Agent (理解意图,路由到子 Agent) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ [Planner] [Executor] [Librarian] ... │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └───────────┼────────────┘ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ Skill Registry │ │ │
|
||||
│ │ │ (可用的 Skills) │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ LLM 自主判断使用哪个 Skill │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 组件说明
|
||||
|
||||
| 组件 | 职责 |
|
||||
|-----|------|
|
||||
| Skill Registry | 存储 Skill 定义,提供加载接口,权限校验 |
|
||||
| Skill Loader | 运行时加载 Agent 对应的 Skills |
|
||||
| Skill Executor | 执行 Skill 指令,调用工具链 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Skill 定义示例
|
||||
|
||||
### 5.1 任务排序 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "任务排序",
|
||||
"description": "根据优先级、截止日期、依赖关系对任务列表进行智能排序",
|
||||
"instructions": "你是一个任务排序专家。接收任务列表后,按以下规则排序:\n1. 紧急且重要优先\n2. 有截止日期的优先\n3. 依赖其他任务的优先\n输出排序后的任务列表及理由。",
|
||||
"agent_type": "planner",
|
||||
"tools": ["get_tasks"],
|
||||
"required_context": ["原始任务列表"]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 知识检索 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "知识检索",
|
||||
"description": "从用户知识库中检索相关内容,支持向量检索和关键词检索",
|
||||
"instructions": "你是一个知识管理员。从知识库中检索与用户问题相关的内容,返回相关文档片段和来源。",
|
||||
"agent_type": "librarian",
|
||||
"tools": ["search_knowledge", "hybrid_search"],
|
||||
"required_context": ["用户查询"]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 数据分析 Skill
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "效率分析",
|
||||
"description": "分析任务完成情况,计算工作效率指标",
|
||||
"instructions": "你是一个数据分析师。接收任务列表后,分析:\n1. 完成率\n2. 平均完成时间\n3. 阻塞原因\n4. 改进建议",
|
||||
"agent_type": "analyst",
|
||||
"tools": ["get_tasks", "get_stats"],
|
||||
"required_context": ["任务数据"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 Skill 管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|-----|
|
||||
| POST | /api/skills | 创建 Skill |
|
||||
| GET | /api/skills | 列表(支持过滤 agent_type, visibility) |
|
||||
| GET | /api/skills/{id} | 详情 |
|
||||
| PUT | /api/skills/{id} | 更新 |
|
||||
| DELETE | /api/skills/{id} | 删除 |
|
||||
|
||||
### 6.2 Skill 执行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|-----|------|-----|
|
||||
| POST | /api/skills/{id}/execute | 手动执行 Skill |
|
||||
| GET | /api/skills/execute/{execution_id} | 查询执行结果 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据库表设计
|
||||
|
||||
### 7.1 skill 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE skill (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
instructions TEXT NOT NULL,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
tools JSONB DEFAULT '[]',
|
||||
required_context JSONB DEFAULT '[]',
|
||||
output_format TEXT,
|
||||
visibility VARCHAR(20) DEFAULT 'private',
|
||||
team_id UUID,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
owner_id UUID NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skill_agent_type ON skill(agent_type);
|
||||
CREATE INDEX idx_skill_visibility ON skill(visibility);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端界面
|
||||
|
||||
### 8.1 Skill 管理入口
|
||||
|
||||
入口位置:智能链路 → Skill 市场
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 智能链路 → Skill 市场 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [我的 Skills] [团队共享] [市场] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 任务排序 │ │
|
||||
│ │ 适用: Planner Agent │ │
|
||||
│ │ 工具: get_tasks │ │
|
||||
│ │ 描述: 根据优先级排序任务列表 │ │
|
||||
│ │ 可见: 私有 │ │
|
||||
│ │ [编辑] [禁用] [复制] │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Skill 编辑界面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 创建/编辑 Skill │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 名称: [________________] │
|
||||
│ 描述: [________________] │
|
||||
│ │
|
||||
│ 适用 Agent: │
|
||||
│ ( ) Master (●) Planner ( ) Executor │
|
||||
│ ( ) Librarian ( ) Analyst │
|
||||
│ │
|
||||
│ 引用工具: │
|
||||
│ ☑ get_tasks ☑ create_task │
|
||||
│ ☐ search_knowledge ☐ hybrid_search │
|
||||
│ │
|
||||
│ 指令模板: │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 你是一个任务排序专家... │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 可见性: │
|
||||
│ (●) 私有 ( ) 团队共享 ( ) 公开市场 │
|
||||
│ │
|
||||
│ [取消] [保存] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有系统集成
|
||||
|
||||
| 现有组件 | 集成方式 |
|
||||
|---------|---------|
|
||||
| Agent Role | Skill.agent_type 引用现有 AgentRole 枚举 |
|
||||
| Tools | Skill.tools 引用现有 ALL_TOOLS 中的工具名 |
|
||||
| Prompts | Skill.instructions 作为 Agent 系统提示的补充 |
|
||||
| User/Team | 复用现有权限体系,visibility 字段控制 |
|
||||
| Router | Master Agent 路由逻辑不变,Skill 由子 Agent 按需调用 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现计划
|
||||
|
||||
### Phase 1: 基础框架
|
||||
- [ ] Skill 数据模型与 CRUD API
|
||||
- [ ] Skill Registry 服务
|
||||
- [ ] Skill 加载机制(Agent 初始化时注入)
|
||||
- [ ] 前端 Skill 管理界面
|
||||
|
||||
### Phase 2: 执行机制
|
||||
- [ ] Skill Executor
|
||||
- [ ] 工具调用桥接
|
||||
- [ ] 执行结果返回
|
||||
|
||||
### Phase 3: 高级特性
|
||||
- [ ] 团队共享机制
|
||||
- [ ] Skill 市场
|
||||
- [ ] Skill 编排(多个 Skill 串联)
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与约束
|
||||
|
||||
1. **LLM 自主性**:依赖 LLM 准确理解 Skill 描述,需优化 prompt
|
||||
2. **工具兼容性**:Skill 引用的工具需在 ALL_TOOLS 中存在
|
||||
3. **权限控制**:团队共享需防止越权访问
|
||||
@@ -1,561 +0,0 @@
|
||||
# Schedule Planner Agent Redesign
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current planner role with a schedule-focused planning system that analyzes conversation history, the task board, and forum signals to produce actionable scheduling recommendations for the user.
|
||||
|
||||
## Scope
|
||||
|
||||
This redesign covers both the main planner role and its subagents across backend orchestration, prompts, routing, scheduled execution, todo generation, frontend presentation, and related tests.
|
||||
|
||||
## User-Approved Direction
|
||||
|
||||
- Replace the current path-planning semantics with schedule-planning semantics.
|
||||
- Redesign both the main planner role and its subagents.
|
||||
- Inputs for planning:
|
||||
- conversation history
|
||||
- task board
|
||||
- forum information
|
||||
- Output style:
|
||||
- conclusion first
|
||||
- executable schedule next
|
||||
- Trigger modes:
|
||||
- when the user explicitly asks for scheduling advice
|
||||
- at a fixed daily time
|
||||
- Daily scheduled analysis should write actionable suggestions into todo items.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Role
|
||||
|
||||
The current `planner` role will be replaced at the system level by a new role id:
|
||||
|
||||
- `schedule_planner`
|
||||
|
||||
Its responsibility is no longer “find the shortest execution path for a goal.” Instead, it becomes the scheduling brain that:
|
||||
|
||||
1. understands current commitments and pressure signals
|
||||
2. evaluates urgency, importance, dependency, and timing
|
||||
3. recommends near-term scheduling actions
|
||||
4. converts useful scheduled guidance into concrete todo items when triggered by the daily scheduler
|
||||
|
||||
### Subagents
|
||||
|
||||
The existing planner subagent structure will be redesigned into two schedule-specific subagents:
|
||||
|
||||
- `schedule_analysis`
|
||||
- analyzes conversation history, task board state, and forum signals
|
||||
- identifies priorities, pressure points, conflicts, dependencies, risks, and things that can be delayed
|
||||
|
||||
- `schedule_planning`
|
||||
- converts analysis into an execution-oriented schedule recommendation
|
||||
- outputs conclusion first, then a practical schedule proposal
|
||||
- when running from the daily scheduled workflow, produces todo-ready action items
|
||||
|
||||
### Trigger Paths
|
||||
|
||||
#### Interactive Trigger
|
||||
|
||||
When the user asks questions such as:
|
||||
|
||||
- what should I do today
|
||||
- how should I arrange this week
|
||||
- based on my recent work, what should I focus on next
|
||||
- help me schedule upcoming work
|
||||
|
||||
The master agent should route to `schedule_planner`.
|
||||
|
||||
The expected response shape:
|
||||
|
||||
1. current conclusion
|
||||
2. today / near-term schedule recommendation
|
||||
3. next actions
|
||||
|
||||
#### Daily Scheduled Trigger
|
||||
|
||||
A daily scheduled job invokes the schedule planner flow automatically.
|
||||
|
||||
The daily run should:
|
||||
|
||||
1. collect relevant context from conversation history, tasks, and forum data
|
||||
2. run `schedule_analysis`
|
||||
3. run `schedule_planning`
|
||||
4. convert only actionable, non-duplicate recommendations into todo items
|
||||
|
||||
The daily run should not dump raw analysis into todos. Only concise, action-worthy, user-meaningful recommendations become todos.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Inputs
|
||||
|
||||
The schedule planning system should read from three sources:
|
||||
|
||||
1. **Conversation history**
|
||||
- recent user intent
|
||||
- commitments implied in prior discussion
|
||||
- stated priorities, urgency, and unresolved threads
|
||||
|
||||
2. **Task board**
|
||||
- open items
|
||||
- current statuses
|
||||
- stalled work
|
||||
- high-priority or overdue work
|
||||
|
||||
3. **Forum information**
|
||||
- new items requiring attention
|
||||
- external pressure or discussion signals
|
||||
- updates that may change priority
|
||||
|
||||
### Internal Processing
|
||||
|
||||
The main flow should be:
|
||||
|
||||
- Master decides scheduling intent
|
||||
- `schedule_planner` receives context
|
||||
- `schedule_analysis` identifies priority structure
|
||||
- `schedule_planning` produces human-usable output
|
||||
- scheduled mode additionally writes selected suggestions into todos
|
||||
|
||||
### Outputs
|
||||
|
||||
#### Interactive Output
|
||||
|
||||
The default answer structure should be:
|
||||
|
||||
- conclusion first
|
||||
- suggested schedule second
|
||||
- next actions last
|
||||
|
||||
#### Scheduled Output
|
||||
|
||||
The scheduled run should create todo entries with:
|
||||
|
||||
- concise action phrasing
|
||||
- enough context to be actionable
|
||||
- source attribution where useful (conversation/task/forum)
|
||||
- duplicate avoidance
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
This redesign uses a two-phase migration to avoid breaking stored state and UI rendering.
|
||||
|
||||
### Phase 1: Compatibility Window
|
||||
|
||||
- accept legacy `planner` values from stored traces, mock payloads, and historical records
|
||||
- normalize legacy `planner` to `schedule_planner` at read boundaries where practical
|
||||
- accept legacy `planner_scope` and `planner_steps` as read-only legacy values and normalize them to `schedule_analysis` and `schedule_planning`
|
||||
- write only the new ids going forward:
|
||||
- `schedule_planner`
|
||||
- `schedule_analysis`
|
||||
- `schedule_planning`
|
||||
|
||||
### Phase 2: Legacy Removal
|
||||
|
||||
After the migration is complete and all active UI payloads, mock data, and tests are updated:
|
||||
|
||||
- remove legacy id acceptance from orchestration and frontend display logic
|
||||
- remove legacy mock fixtures
|
||||
- keep migration code out of prompts and core scheduling behavior
|
||||
|
||||
### Migration Scope
|
||||
|
||||
The migration must cover:
|
||||
|
||||
- backend enums and routing
|
||||
- frontend agent ids and telemetry labels
|
||||
- stored trace rendering paths
|
||||
- mock data used by agent dashboards and chat orchestration views
|
||||
- tests that still refer to `planner`, `planner_scope`, or `planner_steps`
|
||||
|
||||
## Input Contracts
|
||||
|
||||
The schedule planning system reads from three sources with explicit limits.
|
||||
|
||||
### Conversation History Contract
|
||||
|
||||
- use recent conversation history from the current user context
|
||||
- default retrieval window: last 7 days of relevant conversation turns, capped at the latest 50 turns
|
||||
- prefer turns that include commitments, priorities, deadlines, blockers, or future-oriented intent
|
||||
- if conversation history is unavailable, continue with degraded confidence
|
||||
|
||||
### Task Board Contract
|
||||
|
||||
- include open, in-progress, blocked, overdue, and high-priority tasks
|
||||
- exclude completed and archived items by default
|
||||
- include enough task metadata to reason about urgency and dependency:
|
||||
- title
|
||||
- status
|
||||
- priority
|
||||
- due date if present
|
||||
- last updated time if present
|
||||
- if task data is unavailable, continue with degraded confidence
|
||||
|
||||
### Forum Information Contract
|
||||
|
||||
- include recent forum items that may affect user priorities
|
||||
- default retrieval window: last 7 days of relevant forum signals
|
||||
- forum signals may include:
|
||||
- new posts requiring attention
|
||||
- replies or escalations
|
||||
- updates that change urgency or expected follow-up
|
||||
- if forum data is unavailable, continue with degraded confidence
|
||||
|
||||
## Output Contracts
|
||||
|
||||
### `schedule_analysis` Output Schema
|
||||
|
||||
The analysis stage should produce a structured summary with these fields:
|
||||
|
||||
- `top_priorities`: list of current highest-priority focus areas
|
||||
- `risks`: list of risk or pressure signals
|
||||
- `conflicts`: list of timing or dependency conflicts
|
||||
- `deferrable_items`: list of lower-priority items that can be delayed
|
||||
- `evidence`: source references grouped by `conversation`, `task_board`, or `forum`
|
||||
- `confidence`: one of `high`, `medium`, `low`
|
||||
|
||||
### `schedule_planning` Output Schema
|
||||
|
||||
The planning stage should produce a structured recommendation with these fields:
|
||||
|
||||
- `conclusion`: short decision-oriented summary
|
||||
- `today_plan`: list of suggested actions for the current day or immediate next window
|
||||
- `near_term_plan`: list of actions for the next few days or current week
|
||||
- `next_actions`: short ordered action list
|
||||
- `todo_candidates`: only present in scheduled mode; candidate todo items derived from the recommendation
|
||||
- `confidence`: one of `high`, `medium`, `low`
|
||||
|
||||
### `todo_candidates` Schema
|
||||
|
||||
Each `todo_candidate` must use this structure:
|
||||
|
||||
- `title`: required short action text
|
||||
- `description`: required short rationale grounded in source context
|
||||
- `sources`: required list of provenance objects
|
||||
- `priority`: optional normalized priority such as `high`, `medium`, `low`
|
||||
- `target_window`: optional string such as `today` or `this_week`
|
||||
|
||||
Each provenance object in `sources` must contain:
|
||||
|
||||
- `type`: one of `conversation`, `task_board`, `forum`
|
||||
- `id`: source object id when available, otherwise a stable synthetic reference
|
||||
- `label`: short human-readable source label
|
||||
|
||||
### Evidence Structure
|
||||
|
||||
Each item in `schedule_analysis.evidence` must contain:
|
||||
|
||||
- `type`: one of `conversation`, `task_board`, `forum`
|
||||
- `id`: source object id when available, otherwise a stable synthetic reference
|
||||
- `label`: short human-readable identifier
|
||||
- `reason`: brief explanation of why the signal matters to scheduling
|
||||
|
||||
### Interactive Response Contract
|
||||
|
||||
The user-facing answer should always follow this shape:
|
||||
|
||||
1. conclusion
|
||||
2. suggested schedule
|
||||
3. next actions
|
||||
|
||||
If confidence is low, the response must say that explicitly and avoid overconfident scheduling language.
|
||||
|
||||
## Daily Scheduler Contract
|
||||
|
||||
The daily scheduled trigger must follow explicit execution semantics.
|
||||
|
||||
### Execution Model
|
||||
|
||||
- run once per user per local date
|
||||
- default execution time: 07:00 in the user's configured timezone
|
||||
- if the user has no configured timezone, skip the run and log the skip reason
|
||||
- do not automatically backfill missed runs
|
||||
- enforce idempotency by `(user_id, local_date, job_type)` so the same daily analysis is not executed more than once successfully
|
||||
|
||||
### Scheduled Mode Behavior
|
||||
|
||||
A successful scheduled run should:
|
||||
|
||||
1. gather available context from the three input sources
|
||||
2. execute `schedule_analysis`
|
||||
3. execute `schedule_planning`
|
||||
4. create todo items from selected `todo_candidates`
|
||||
5. store run telemetry and outcome metadata
|
||||
|
||||
If one or more sources are missing, continue when there is still enough evidence to produce a useful recommendation and mark confidence as reduced.
|
||||
|
||||
Signal evaluation rules:
|
||||
|
||||
- a **strong source** is a source with enough current evidence to support prioritization on its own, such as multiple open high-priority tasks or a recent forum escalation
|
||||
- a **meaningful signal** is a discrete scheduling-relevant item extracted from any source, such as an overdue task, a stated commitment in conversation history, or a forum escalation
|
||||
- the planner may still run with one strong source
|
||||
- scheduled mode may create todos only when at least two meaningful signals exist across all inputs
|
||||
|
||||
If fewer than two meaningful signals are available across all sources, the scheduler should not create todos and should log a low-context outcome.
|
||||
|
||||
Delayed execution rule:
|
||||
|
||||
- if the 07:00 run is delayed by temporary outage or worker unavailability, the system may still execute one delayed run later on the same user-local date
|
||||
- if the entire local date passes without a successful run, do not backfill on the next day
|
||||
|
||||
## Todo Creation Rules
|
||||
|
||||
Todo creation is the main scheduled side effect and must be tightly constrained.
|
||||
|
||||
### Creation Rules
|
||||
|
||||
- create at most 3 todo items per daily run
|
||||
- only create todos for actions that are concrete, near-term, and user-actionable
|
||||
- do not create todos for vague advice, reflections, or duplicated reminders
|
||||
- store source provenance when available:
|
||||
- `conversation`
|
||||
- `task_board`
|
||||
- `forum`
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
A candidate todo is considered a duplicate if there is already an open todo that matches all of the following:
|
||||
|
||||
- same normalized action text
|
||||
- same source category or same source object when available
|
||||
- created within the last 7 days
|
||||
|
||||
Normalization rules for action text:
|
||||
|
||||
- trim surrounding whitespace
|
||||
- collapse repeated internal whitespace to a single space
|
||||
- lowercase Latin characters
|
||||
- remove trailing full stop / period punctuation only
|
||||
|
||||
Source comparison rules:
|
||||
|
||||
- if a provenance object includes a stable source `id`, compare by `(type, id)`
|
||||
- if no stable source id exists, compare by `(type, normalized label)`
|
||||
- if multiple sources support one recommendation, compare against the highest-priority provenance in this order: `task_board`, `forum`, `conversation`
|
||||
|
||||
When a duplicate is detected:
|
||||
|
||||
- do not create a new todo
|
||||
- record the skip reason in scheduler telemetry
|
||||
|
||||
### Todo Fields
|
||||
|
||||
Scheduled-created todos should include at minimum these persisted fields:
|
||||
|
||||
- `title`: required
|
||||
- `description`: required
|
||||
- `source_type`: required primary provenance type
|
||||
- `source_id`: optional stable source id
|
||||
- `source_label`: required fallback human-readable provenance label
|
||||
- `created_by`: required and set to `schedule_planner`
|
||||
- `created_at`: required timestamp
|
||||
- `priority`: optional normalized priority
|
||||
- `target_window`: optional normalized scheduling window
|
||||
|
||||
## Routing Boundaries
|
||||
|
||||
The system must distinguish scheduling from adjacent planning behaviors.
|
||||
|
||||
### Route to `schedule_planner` when the user asks for:
|
||||
|
||||
- today or this week planning
|
||||
- what to focus on next
|
||||
- priority ordering across ongoing work
|
||||
- time-aware sequencing of current commitments
|
||||
|
||||
### Do not route to `schedule_planner` when the user asks for:
|
||||
|
||||
- deep implementation planning for a feature
|
||||
- code execution or task fulfillment
|
||||
- research-only retrieval
|
||||
- pure analysis without scheduling intent
|
||||
|
||||
In ambiguous cases such as "what should I do next?", prefer `schedule_planner` when the available context includes multiple active tasks, recent commitments, or forum pressure signals.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Role and Graph Layer
|
||||
|
||||
Update the orchestration layer so the planner role is redefined as `schedule_planner` rather than `planner`.
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `backend/app/agents/state.py`
|
||||
- `backend/app/agents/graph.py`
|
||||
- `backend/app/agents/prompts.py`
|
||||
- `backend/app/routers/agent.py`
|
||||
- `backend/app/services/agent_service.py`
|
||||
|
||||
Required changes:
|
||||
|
||||
- rename role ids where appropriate
|
||||
- update graph node registration
|
||||
- update master routing rules
|
||||
- replace planner subagent mappings
|
||||
- update telemetry and sub-commander trace labels
|
||||
|
||||
### Prompt Layer
|
||||
|
||||
Replace the current planner prompt family with schedule-specific instructions.
|
||||
|
||||
Needed prompt families:
|
||||
|
||||
- `SCHEDULE_PLANNER_SYSTEM_PROMPT`
|
||||
- `SCHEDULE_ANALYSIS_PROMPT`
|
||||
- `SCHEDULE_PLANNING_PROMPT`
|
||||
|
||||
Prompt requirements:
|
||||
|
||||
- reason over conversation history, tasks, and forum state
|
||||
- prioritize urgency, importance, and dependency
|
||||
- avoid abstract productivity advice
|
||||
- produce concrete, immediate scheduling output
|
||||
- in scheduled mode, generate todo-worthy suggestions only
|
||||
|
||||
### Scheduled Execution Layer
|
||||
|
||||
Add or update the daily scheduled workflow so it can call the schedule planner flow automatically.
|
||||
|
||||
Likely touchpoints:
|
||||
|
||||
- scheduler service
|
||||
- existing daily planning jobs
|
||||
- todo creation services
|
||||
|
||||
Required behavior:
|
||||
|
||||
- fixed daily execution time
|
||||
- fetch relevant context
|
||||
- call schedule planner pipeline
|
||||
- write selected recommendations into todos
|
||||
- skip duplicate todo creation
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
Frontend needs to reflect the new role system consistently.
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `frontend/src/data/agents.ts`
|
||||
- `frontend/src/pages/agents/index.vue`
|
||||
- `frontend/src/components/chat/OrchestrationPanel.vue`
|
||||
- `frontend/src/pages/chat/composables/useChatView.ts`
|
||||
- related frontend tests
|
||||
|
||||
Required updates:
|
||||
|
||||
- replace planner display labels with schedule planner labels
|
||||
- rename planner subagents to schedule analysis / schedule planning
|
||||
- update orchestration telemetry labels
|
||||
- update example mock state and tests
|
||||
- use these exact frontend ids:
|
||||
- `schedule_planner`
|
||||
- `schedule_analysis`
|
||||
- `schedule_planning`
|
||||
- use these exact default Chinese labels:
|
||||
- `日程规划师`
|
||||
- `日程分析员`
|
||||
- `日程编排员`
|
||||
- update active route visualization and commander skill labels to the new ids
|
||||
|
||||
## Naming
|
||||
|
||||
### Main Agent
|
||||
|
||||
- old: `planner`
|
||||
- new: `schedule_planner`
|
||||
- display role: `日程规划师`
|
||||
|
||||
### Subagents
|
||||
|
||||
- old: `planner_scope`
|
||||
- new: `schedule_analysis`
|
||||
- display role: `日程分析员`
|
||||
|
||||
- old: `planner_steps`
|
||||
- new: `schedule_planning`
|
||||
- display role: `日程编排员`
|
||||
|
||||
## Constraints
|
||||
|
||||
- do not keep dual role names for long-term compatibility unless a specific dependency forces it
|
||||
- do not create todos for every suggestion
|
||||
- do not turn the planner into a generic life coach
|
||||
- keep scheduling grounded in current project signals
|
||||
- preserve the existing agent architecture where possible, while fully changing planner semantics
|
||||
|
||||
## Observability
|
||||
|
||||
The redesign must emit enough telemetry to debug routing and scheduled execution.
|
||||
|
||||
Required telemetry fields:
|
||||
|
||||
- selected main route
|
||||
- selected subagent
|
||||
- available input sources
|
||||
- missing input sources
|
||||
- run mode: `interactive` or `scheduled`
|
||||
- confidence level
|
||||
- todos created count
|
||||
- todos skipped as duplicates count
|
||||
- scheduler run success / skipped / failed
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Backend Acceptance Criteria
|
||||
|
||||
- a scheduling-intent user query routes to `schedule_planner`
|
||||
- `schedule_analysis` and `schedule_planning` are both reachable through the orchestration layer
|
||||
- legacy planner ids are normalized during the compatibility window
|
||||
- daily scheduled runs do not execute more than once per user per local date
|
||||
- low-context daily runs do not create todos
|
||||
- duplicate todo candidates are skipped instead of recreated
|
||||
|
||||
### Frontend Acceptance Criteria
|
||||
|
||||
- the agents page displays `日程规划师` instead of the previous planner label
|
||||
- the planner subagent chips display `日程分析员` and `日程编排员`
|
||||
- orchestration mock data and route highlights use the new ids
|
||||
- tests no longer depend on `planner_scope` or `planner_steps` after migration is complete
|
||||
|
||||
### Failure and Fallback Criteria
|
||||
|
||||
- if forum data is missing, the planner still runs with degraded confidence
|
||||
- if task board data is missing, the planner still runs with degraded confidence when other strong context exists
|
||||
- if fewer than two meaningful signals are available, scheduled mode creates no todos
|
||||
- if the user has no timezone configured, the daily scheduled run is skipped and logged
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- master routing to `schedule_planner`
|
||||
- schedule subagent selection behavior
|
||||
- prompt invariants for schedule-focused output
|
||||
- scheduled daily run creates todos from actionable suggestions
|
||||
- duplicate todo protection
|
||||
|
||||
### Frontend
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- renamed main role and subagent labels
|
||||
- orchestration panel route display
|
||||
- active subagent telemetry
|
||||
- mock agent graph data using `schedule_planner`, `schedule_analysis`, and `schedule_planning`
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Broad rename surface**
|
||||
- `planner` is referenced across backend and frontend, so a full rename must be systematic
|
||||
|
||||
2. **Scheduled todo spam**
|
||||
- daily runs may create low-value or duplicate todos unless filtered carefully
|
||||
|
||||
3. **Prompt drift**
|
||||
- if prompts stay too abstract, the new agent will sound renamed but not actually scheduling-oriented
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implement this as a real role-system redesign, not as a display-only rename. The role id, subagent ids, prompt family, routing logic, and frontend telemetry should all align on the new scheduling semantics so the system remains internally coherent.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user