feat(agents): Phase 8.4-10.5 built-in plugins, bundled skills, coordinator

This commit is contained in:
2026-04-04 23:24:34 +08:00
parent 88955ed550
commit d18167826e
105 changed files with 14780 additions and 15685 deletions

View 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

View 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

View 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

View File

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

View 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",
]

View 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",
},
}

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

View 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(),
},
}

View 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]

View File

@@ -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": {}
}

View File

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

View File

@@ -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": {}
}

View 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]

View 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": {}
}

View 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]

View 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": {}
}

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

View File

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

View 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"],
},
]

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

52
development-doc/README.md Normal file
View 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 文件
- 分步骤执行时,已完成项使用 `~~删除线~~` 标记

View 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 4Phase 4 可视化 API 实现
5. 设计隔离执行最小方案
---
## 每日维护要求
后续正式进入改造阶段后,本文件需要持续更新:
1. 开始开发前更新“今日开发计划”
2. 完成一个阶段性步骤后更新“当前进度”
3. 变更方案时更新“风险与临时决策”
4. 出现问题时更新“当前阻塞点”
5. 每次完成验证后更新“验证 / 测试情况”
6. 一天结束前补齐“已完成 / 未完成 / 下一步计划”

View 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 待启动
- 待实现可见性 APIevent 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. 跑测试验证主流程不回退

View File

@@ -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 下,所有复杂请求返回前都必须经过 verifierverifier 有权拒绝证据不足、结果不完整、子任务未闭环的响应。
- [ ] 补 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 底座

View File

@@ -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] 支持定时和异步后台任务

View 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 重连 | 前端实现自动重连机制 |

View 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 1State + 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 2AI 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 3Security 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 4Sandbox 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 6Graph 节点 + 边路由
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 7PTY Terminal Engine
Day 7 目标:实现 PTY 终端管理
- [ ] 新增 `PTYSession` 数据类
- [ ] 新增 `PTYManager`
- `spawn()` 方法
- `write()` 方法
- `read()` 方法(异步生成器)
- `resize()` 方法
- `kill()` 方法
- [ ] 实现 `asyncio.subprocess` 进程管理
- [ ] 实现输出队列
- [ ] 补 Day 7 单元测试
**验收PTY 会话可以启动、读写、终止**
---
## Day 8WebSocket + 流式输出
Day 8 目标:实现 WebSocket 端点和流式输出
- [ ] 新增 `ConnectionManager`
- [ ] 新增 `/ws/terminal/{session_id}` WebSocket 端点
- [ ] 实现连接管理connect/disconnect
- [ ] 新增 `StreamOutput`
- [ ] 实现 `stream_execution()` 方法
- [ ] 新增 `InteractiveInputHandler`
- [ ] 实现用户输入传递到 PTY
- [ ] 补 Day 8 集成测试
**验收WebSocket 连接正常,输出实时推送**
---
## Day 9Vue 页面组件
Day 9 目标:前端代码指挥官主页面
- [ ] 新增 `CodeCommander.vue` 页面组件
- AI 提供商选择器
- 任务输入框
- 执行按钮
- 终端显示区域
- 交互输入框
- 下载/清理按钮
- [ ] 补 Day 9 组件测试
**验收:用户可以选择 AI 提供商并输入任务**
---
## Day 10TerminalDisplay + WebSocket 服务 + 路由
Day 10 目标:完成前端集成
- [ ] 新增 `TerminalDisplay.vue` 组件xterm.js
- 终端渲染
- ANSI 颜色支持
- 用户输入处理
- [ ] 新增 `terminalWs.ts` WebSocket 服务
- 连接管理
- 自动重连
- 消息处理
- [ ]`router/index.ts` 新增 `/code-commander` 路由
- [ ] 端到端测试:完整执行流程
- [ ] 确认前端与后端 WebSocket 通信正常
**验收:用户可以在前端看到实时终端输出并交互**
---
## 最终验收
- [ ] 用户可以选择 AI 提供商Claude/Gemini/Codex/OpenCode
- [ ] 低风险任务(如贪食蛇 demo直接执行
- [ ] 高风险任务在临时目录沙盒执行
- [ ] 终端输出实时流式显示
- [ ] 用户可以中途输入交互(如 "y" 确认)
- [ ] 临时目录执行后正确清理
- [ ] 前端页面正常展示
- [ ] 回归测试通过(现有功能不受影响)

View 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 3Agent 集成)
```
本阶段是后续所有阶段的基础。

View 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 3Agent 集成)
→ Phase 4流式交互
```

View File

@@ -0,0 +1,162 @@
# Phase 3Agent 集成
日期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前端集成
```

View File

@@ -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 3Agent 集成)
本阶段 → Phase 5前端集成
```
---
## 6. 备注
PTY 实现参考了 golutra 的 `src-tauri/src/runtime/pty.rs`
- 使用 `portable-pty`
- Windows 路径兼容处理
- shim 机制用于信号处理

View 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流式交互
本阶段(前端集成)→ 端到端测试
```

View 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.1F.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 天** |

View 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.2API 增强与安全
### 目标
增强 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.4AI 集成
### 目标
实现 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 | 创建文档 |

View File

@@ -0,0 +1,205 @@
# Phase F.0Forum 现状与目标
日期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 文档能够在这个认知基础上展开

View 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
- [ ] 迁移脚本可回滚
- [ ] 单元测试覆盖新增功能

View File

@@ -0,0 +1,488 @@
# Phase F.2API 增强与安全
日期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 保持向后兼容
- [ ] 单元测试覆盖核心逻辑

View 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
- [ ] 所有管理操作记录日志
- [ ] 积分根据规则正确增减
- [ ] 排行榜正确排序
- [ ] 禁言功能正常工作
- [ ] 单元测试覆盖核心逻辑

View File

@@ -0,0 +1,652 @@
# Phase F.4AI 集成
日期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 端点正常工作
- [ ] 单元测试覆盖核心逻辑

View 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:
- 知道什么对你重要(频率+情绪+影响面)
- 知道什么是你的痛点(反复问的问题)
- 会主动提醒你关心的事(不是等用户问)
- 知道什么可以忘记(低频记忆自然衰减)
```

View 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 并行推进。**

View File

@@ -0,0 +1,125 @@
# Phase M.0Memory 现状与目标
日期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 文档能够在这个认知基础上展开

View File

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

View File

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

View File

@@ -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 新增
```
提醒系统独立于现有任务系统,但可以复用调度基础设施。

View 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.1R.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 可选,暂不实现 |

View 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.1Token 感知分块优化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.3EPA 分析设计(可选探索)
- [ ] 设计 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 并行推进。**

View File

@@ -0,0 +1,156 @@
# Phase R.0RAG 现状与目标
日期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 文档能够在这个认知基础上展开

View File

@@ -0,0 +1,188 @@
# Phase R.1Token 感知分块优化
日期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 天** |

View File

@@ -0,0 +1,244 @@
# Phase R.2:多索引架构
日期2026-04-03
状态:已规划
依赖R.1Token 感知分块)
工作量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 天** |

View File

@@ -0,0 +1,290 @@
# Phase R.3:动态权重增强
日期2026-04-03
状态:已规划
依赖R.1Token 感知分块)
工作量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 天** |

View 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.3EPA 分析设计(探索)
**目标:** 语义空间投影分析方案设计
```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 可选,暂不实现 |
| 聚类计算开销 | 中 | 限制聚类数量,使用高效算法 |
| 去重阈值调参 | 中 | 提供配置项,允许用户调整 |

View File

@@ -0,0 +1,601 @@
# Phase RRAG 系统升级专项
日期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.1EPA 分析(探索)
```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 可选,暂不实现 |

View 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 天** |

View 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.1Manifest 驱动系统
### 目标
建立 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 | 创建文档 |

View File

@@ -0,0 +1,192 @@
# Phase T.0Tools 现状与目标
日期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 文档能够在这个认知基础上展开

View File

@@ -0,0 +1,484 @@
# Phase T.1Manifest 驱动系统
日期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 验证器可捕获错误
- [ ] 单元测试覆盖核心逻辑

View 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 可使用注册的工具
- [ ] 单元测试通过

View File

@@ -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 可管理任务
- [ ] 所有工具注册到工具中心
- [ ] 单元测试通过

View 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 调用
- [ ] 原生运行时可执行二进制
- [ ] 运行时管理器正确路由
- [ ] 协作协议可正常请求/响应
- [ ] 定时调度器可按计划执行任务
- [ ] 单元测试通过

View File

@@ -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可切换 |
| **知识库框架** | LlamaIndexNode 关系索引、语义检索) |
| **向量数据库** | ChromaDB |
| **关系数据库** | SQLite + SQLAlchemy |
| **前端框架** | Vue 3 + TypeScript + Vite |
| **移动端** | Kotlin (Android) |
| **定时任务** | APScheduler |
| **部署** | DockerNAS 本地运行) |
---
## 目录结构
```
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`
是否现在开始?

View File

@@ -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_idsAgentService 读取文件内容作为上下文
**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** - 在当前会话中按批次执行任务
选择哪种方式?

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 消耗统计

View File

@@ -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 6LoginView 注册功能)
4. Task 7SettingsView
5. Task 8路由和侧边栏
6. Task 9数据库迁移

View File

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

View File

@@ -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:** 实现一个层级标签系统,标签作为 KGNodeentity_type="tag"),支持 AI 自动生成标签、标签关系网络、内容关联发现。
**Architecture:**
- 标签存储为 KGNodeentity_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` |

View File

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

View File

@@ -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` 卡片列表 UItemplate 中的 `.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"
```

View File

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

View File

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

View File

@@ -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` 接口上传文件
- 创建 KGNodeentity_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
```

View File

@@ -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:00APScheduler 清理 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:00APScheduler 定时任务),也可手动触发
- 数据来源:
1. **看板任务**:前一天创建的、状态 ≠ done 的任务,取前 20 条(按 created_at 倒序)
2. **对话记录**:前一天创建的对话,取其消息内容前 2000 字发给 LLM
- AI 处理流程:
1. 查询上述数据,拼装为分析文本
2. 发送给 LLMPrompt 要求输出 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

View File

@@ -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 关系抽取(实体之间的关系)
└── 存入 SQLitenodes + 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 助理系统的初始设计规格,将根据开发进展持续更新。*

View File

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

View File

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

View File

@@ -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 消耗统计

View File

@@ -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. 注册表单验证正常 - 密码强度、格式校验有效

View File

@@ -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等宽加粗
- 趋势图:高度 24px7个数据点
- 标签9pxletter-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. 数据刷新
- 页面进入时加载所有数据
- 支持手动刷新按钮(每个模块独立刷新)
- 数字变化时无动画(避免干扰)

View File

@@ -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生成摘要 → 前端展示
```

View File

@@ -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` 做路径前缀匹配
- 前端递归组件注意防止无限循环

View File

@@ -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 同步**: 每次保存成功后更新原始配置副本

View File

@@ -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 | 团队 IDvisibility=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. **权限控制**:团队共享需防止越权访问

View File

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