Update agent orchestration and knowledge flow

Add sub-commander orchestration updates, align frontend integrations, and refine knowledge view behavior without including local data artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:44:04 +08:00
parent aafa05dc1c
commit 0d89325b09
14 changed files with 529 additions and 650 deletions

View File

@@ -11,8 +11,16 @@ from app.agents.prompts import (
EXECUTOR_SYSTEM_PROMPT,
LIBRARIAN_SYSTEM_PROMPT,
ANALYST_SYSTEM_PROMPT,
PLANNER_SCOPE_PROMPT,
PLANNER_STEPS_PROMPT,
EXECUTOR_TASKS_PROMPT,
EXECUTOR_FORUM_PROMPT,
LIBRARIAN_RETRIEVAL_PROMPT,
LIBRARIAN_GRAPH_PROMPT,
ANALYST_PROGRESS_PROMPT,
ANALYST_INSIGHTS_PROMPT,
)
from app.agents.tools import ALL_TOOLS
from app.agents.tools import ALL_TOOLS, SUB_COMMANDER_TOOLSETS
from app.agents.skill_registry import build_skill_context
from app.services.llm_service import get_llm
from langchain_openai import ChatOpenAI
@@ -21,6 +29,32 @@ from langchain_ollama import ChatOllama
import httpx
SUB_COMMANDER_PROMPTS = {
"planner_scope": PLANNER_SCOPE_PROMPT,
"planner_steps": PLANNER_STEPS_PROMPT,
"executor_tasks": EXECUTOR_TASKS_PROMPT,
"executor_forum": EXECUTOR_FORUM_PROMPT,
"librarian_retrieval": LIBRARIAN_RETRIEVAL_PROMPT,
"librarian_graph": LIBRARIAN_GRAPH_PROMPT,
"analyst_progress": ANALYST_PROGRESS_PROMPT,
"analyst_insights": ANALYST_INSIGHTS_PROMPT,
}
ROLE_SUB_COMMANDERS = {
AgentRole.PLANNER: ["planner_scope", "planner_steps"],
AgentRole.EXECUTOR: ["executor_tasks", "executor_forum"],
AgentRole.LIBRARIAN: ["librarian_retrieval", "librarian_graph"],
AgentRole.ANALYST: ["analyst_progress", "analyst_insights"],
}
ROLE_SKILL_CONTEXT = {
AgentRole.PLANNER: "planner",
AgentRole.EXECUTOR: "executor",
AgentRole.LIBRARIAN: "librarian",
AgentRole.ANALYST: "analyst",
}
def _create_llm_from_config(config: dict):
"""根据用户模型配置创建 LLM 实例"""
provider = config.get("provider", "openai")
@@ -71,8 +105,9 @@ async def _ainvoke(llm, messages: list[BaseMessage]):
return await llm.invoke(messages)
async def _ainvoke_with_tools(llm, messages: list[BaseMessage]):
bound_llm = llm.bind_tools(ALL_TOOLS)
async def _ainvoke_with_tools(llm, messages: list[BaseMessage], tools=None):
toolset = tools if tools is not None else ALL_TOOLS
bound_llm = llm.bind_tools(toolset)
if hasattr(bound_llm, "ainvoke"):
return await bound_llm.ainvoke(messages)
return await bound_llm.invoke(messages)
@@ -116,6 +151,113 @@ def _is_capability_question(text: str) -> bool:
return normalized in {"你能做什么", "你可以做什么", "你会做什么"}
def _choose_sub_commander(role: AgentRole, user_query: str) -> str:
text = _normalize_user_text(user_query)
if role == AgentRole.PLANNER:
if any(keyword in text for keyword in ["步骤", "计划", "拆解", "排期", "优先级", "路线"]):
return "planner_steps"
return "planner_scope"
if role == AgentRole.EXECUTOR:
if any(keyword in text for keyword in ["论坛", "帖子", "发帖", "指令", "discussion", "instruction"]):
return "executor_forum"
return "executor_tasks"
if role == AgentRole.LIBRARIAN:
if any(keyword in text for keyword in ["图谱", "关系", "构建", "沉淀", "节点", "graph"]):
return "librarian_graph"
return "librarian_retrieval"
if role == AgentRole.ANALYST:
if any(keyword in text for keyword in ["趋势", "风险", "洞察", "建议", "机会", "insight"]):
return "analyst_insights"
return "analyst_progress"
return ROLE_SUB_COMMANDERS[role][0]
def _record_sub_commander(state: AgentState, sub_commander: str, user_query: str):
state["current_sub_commander"] = sub_commander
state["active_sub_commanders"] = state.get("active_sub_commanders", []) + [sub_commander]
state["sub_commander_trace"] = state.get("sub_commander_trace", []) + [{
"agent": state.get("current_agent", AgentRole.MASTER).value,
"sub_commander": sub_commander,
"query": user_query,
}]
def _build_system_messages(state: AgentState, system_prompt: str, role: AgentRole):
system_msgs: list[BaseMessage] = [SystemMessage(content=system_prompt)]
skill_ctx = build_skill_context(ROLE_SKILL_CONTEXT[role])
if skill_ctx:
system_msgs.append(SystemMessage(content=skill_ctx))
return system_msgs
async def _run_sub_commander(
state: AgentState,
role: AgentRole,
manager_prompt: str,
user_query: str,
*,
use_tools: bool,
summary_target: str | None = None,
):
llm = _get_llm_for_state(state)
sub_commander = _choose_sub_commander(role, user_query)
_record_sub_commander(state, sub_commander, user_query)
toolset = SUB_COMMANDER_TOOLSETS.get(sub_commander, [])
system_msgs = _build_system_messages(state, manager_prompt, role)
system_msgs.append(SystemMessage(content=f"本次应由子指挥官 `{sub_commander}` 接手。请严格按该角色职责输出。"))
system_msgs.append(SystemMessage(content=SUB_COMMANDER_PROMPTS[sub_commander]))
if use_tools and toolset:
response = await _ainvoke_with_tools(
llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")],
toolset,
)
tool_calls = getattr(response, "tool_calls", None) or []
if tool_calls:
results = []
for tc in tool_calls:
tool_name = tc.get("name")
args = tc.get("args", {})
for tool in toolset:
if tool.name == tool_name:
try:
result = tool.invoke(args)
results.append(f"[{tool_name}] {result}")
except Exception as e:
results.append(f"[{tool_name}] 执行失败: {e}")
break
state["tool_calls"] = tool_calls
state["last_tool_result"] = "\n".join(results)
follow_up = await _ainvoke(
llm,
[
SystemMessage(content=SUB_COMMANDER_PROMPTS[sub_commander]),
HumanMessage(content=f"工具执行结果:\n{state['last_tool_result']}")
]
)
state["final_response"] = follow_up.content
else:
state["final_response"] = response.content
else:
response = await _ainvoke(
llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")],
)
state["final_response"] = response.content
if summary_target:
state[summary_target] = state.get("final_response", "")
state["should_respond"] = True
return state
# ===================== 节点定义 (async) =====================
async def master_node(state: AgentState) -> AgentState:
@@ -142,14 +284,13 @@ async def master_node(state: AgentState) -> AgentState:
llm = _get_llm_for_state(state)
system_msgs: list[BaseMessage] = [SystemMessage(content=MASTER_SYSTEM_PROMPT)]
# 注入记忆上下文
memory_ctx = state.get("memory_context")
if memory_ctx:
system_msgs.append(
SystemMessage(content=f"\n\n【记忆上下文】\n{memory_ctx}\n\n---\n")
)
response: AIMessage = await _ainvoke(llm,system_msgs + messages)
response: AIMessage = await _ainvoke(llm, system_msgs + messages)
content = response.content.strip().lower()
if any(kw in content for kw in ["搜索", "查找", "知识", "检索"]):
@@ -166,6 +307,7 @@ async def master_node(state: AgentState) -> AgentState:
return state
state["current_agent"] = next_agent
state["current_sub_commander"] = None
state["active_agents"] = state.get("active_agents", [AgentRole.MASTER]) + [next_agent]
state["should_respond"] = True
return state
@@ -173,164 +315,30 @@ async def master_node(state: AgentState) -> AgentState:
async def planner_node(state: AgentState) -> AgentState:
"""规划Agent节点: 制定计划,拆解任务步骤"""
llm = _get_llm_for_state(state)
user_msgs = _filter_user_messages(state["messages"])
user_query = user_msgs[-1].content if user_msgs else ""
system_msgs = [SystemMessage(content=PLANNER_SYSTEM_PROMPT)]
skill_ctx = build_skill_context("planner")
if skill_ctx:
system_msgs.append(SystemMessage(content=skill_ctx))
response = await _ainvoke(llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
)
plan_text = response.content
steps = []
for i, line in enumerate(plan_text.split("\n")):
if line.strip() and (line[0].isdigit() or "- " in line):
steps.append({"step": i + 1, "description": line.strip()})
state["plan"] = plan_text
state["plan_steps"] = steps
state["final_response"] = plan_text
state["should_respond"] = True
return state
return await _run_sub_commander(state, AgentRole.PLANNER, PLANNER_SYSTEM_PROMPT, user_query, use_tools=False)
async def executor_node(state: AgentState) -> AgentState:
"""执行Agent节点: 调用工具执行具体任务"""
llm = _get_llm_for_state(state)
user_msgs = _filter_user_messages(state["messages"])
user_query = user_msgs[-1].content if user_msgs else ""
system_msgs = [SystemMessage(content=EXECUTOR_SYSTEM_PROMPT)]
skill_ctx = build_skill_context("executor")
if skill_ctx:
system_msgs.append(SystemMessage(content=skill_ctx))
response = await _ainvoke_with_tools(llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
)
tool_calls = getattr(response, "tool_calls", None) or []
if tool_calls:
results = []
for tc in tool_calls:
tool_name = tc.get("name")
args = tc.get("args", {})
for tool in ALL_TOOLS:
if tool.name == tool_name:
try:
result = tool.invoke(args)
results.append(f"[{tool_name}] {result}")
except Exception as e:
results.append(f"[{tool_name}] 执行失败: {e}")
break
state["tool_calls"] = tool_calls
state["last_tool_result"] = "\n".join(results)
follow_up = await _ainvoke(llm,
[SystemMessage(content=EXECUTOR_SYSTEM_PROMPT),
HumanMessage(content=f"工具执行结果:\n{state['last_tool_result']}")]
)
state["final_response"] = follow_up.content
else:
state["final_response"] = response.content
state["should_respond"] = True
return state
return await _run_sub_commander(state, AgentRole.EXECUTOR, EXECUTOR_SYSTEM_PROMPT, user_query, use_tools=True)
async def librarian_node(state: AgentState) -> AgentState:
"""知识管理员节点: 管理知识库和知识图谱"""
llm = _get_llm_for_state(state)
user_msgs = _filter_user_messages(state["messages"])
user_query = user_msgs[-1].content if user_msgs else ""
system_msgs = [SystemMessage(content=LIBRARIAN_SYSTEM_PROMPT)]
skill_ctx = build_skill_context("librarian")
if skill_ctx:
system_msgs.append(SystemMessage(content=skill_ctx))
response = await _ainvoke_with_tools(llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
)
tool_calls = getattr(response, "tool_calls", None) or []
if tool_calls:
results = []
for tc in tool_calls:
tool_name = tc.get("name")
args = tc.get("args", {})
for tool in ALL_TOOLS:
if tool.name == tool_name:
try:
result = tool.invoke(args)
results.append(f"[{tool_name}] {result}")
except Exception as e:
results.append(f"[{tool_name}] 执行失败: {e}")
break
state["tool_calls"] = tool_calls
state["last_tool_result"] = "\n".join(results)
follow_up = await _ainvoke(llm,
[SystemMessage(content=LIBRARIAN_SYSTEM_PROMPT),
HumanMessage(content=f"工具执行结果:\n{state['last_tool_result']}")]
)
state["final_response"] = follow_up.content
else:
state["final_response"] = response.content
state["knowledge_context"] = state.get("last_tool_result", "")
state["should_respond"] = True
return state
return await _run_sub_commander(state, AgentRole.LIBRARIAN, LIBRARIAN_SYSTEM_PROMPT, user_query, use_tools=True, summary_target="knowledge_context")
async def analyst_node(state: AgentState) -> AgentState:
"""分析师节点: 分析工作数据,生成报告"""
llm = _get_llm_for_state(state)
user_msgs = _filter_user_messages(state["messages"])
user_query = user_msgs[-1].content if user_msgs else ""
system_msgs = [SystemMessage(content=ANALYST_SYSTEM_PROMPT)]
skill_ctx = build_skill_context("analyst")
if skill_ctx:
system_msgs.append(SystemMessage(content=skill_ctx))
response = await _ainvoke_with_tools(llm,
system_msgs + [HumanMessage(content=f"用户请求: {user_query}")]
)
tool_calls = getattr(response, "tool_calls", None) or []
if tool_calls:
results = []
for tc in tool_calls:
tool_name = tc.get("name")
args = tc.get("args", {})
for tool in ALL_TOOLS:
if tool.name == tool_name:
try:
result = tool.invoke(args)
results.append(f"[{tool_name}] {result}")
except Exception as e:
results.append(f"[{tool_name}] 执行失败: {e}")
break
state["tool_calls"] = tool_calls
state["last_tool_result"] = "\n".join(results)
follow_up = await _ainvoke(llm,
[SystemMessage(content=ANALYST_SYSTEM_PROMPT),
HumanMessage(content=f"工具执行结果:\n{state['last_tool_result']}")]
)
state["final_response"] = follow_up.content
else:
state["final_response"] = response.content
state["analysis_report"] = state.get("final_response", "")
state["should_respond"] = True
return state
return await _run_sub_commander(state, AgentRole.ANALYST, ANALYST_SYSTEM_PROMPT, user_query, use_tools=True, summary_target="analysis_report")
def route_agent(state: AgentState) -> str:

View File

@@ -114,85 +114,46 @@ MASTER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
PLANNER_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 Jarvis 的规划Agent负责制定计划、拆解任务
你是 Jarvis 的规划Agent负责先判断问题该由哪位规划子指挥官接手
## 你的能力:
- 分析复杂请求,拆解成可执行的步骤
- 评估任务优先级
- 判断哪些步骤依赖前置条件
- 制定清晰的执行顺序
## 你的两个子指挥官:
1. **planner_scope (目标收束官)**: 负责澄清目标、边界、约束、缺失信息
2. **planner_steps (步骤拆解官)**: 负责把目标拆成步骤、优先级与依赖关系
## 工作流程:
1. 理解用户的最终目标
2. 判断任务复杂度与关键约束
3. 拆解成具体步骤
4. 标注优先级或先后顺序
5. 给出清晰计划
## 响应要求:
- 用编号列表展示计划步骤
- 每步都要具体,避免空泛词汇
- 必要时可标注 P1/P2/P3 或“先做/后做”
- 如果任务确实复杂,可以轻微指出复杂点,但马上收束到行动方案
- 如果需要执行,先输出计划,再等待用户确认
## 你的职责:
- 判断当前请求更适合收束目标,还是拆解步骤
- 在必要时收束子指挥官输出,面向用户给出清晰结果
- 保持结果可推进,不空泛
"""
EXECUTOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 Jarvis 的执行Agent负责执行具体任务
你是 Jarvis 的执行Agent负责先判断问题该由哪位执行子指挥官接手
## 你可以使用的工具:
- create_task: 创建新任务
- update_task_status: 更新任务状态
- get_tasks: 查看任务列表
- create_forum_post: 在论坛发布帖子
- get_forum_posts: 查看论坛帖子
- scan_forum_for_instructions: 扫描论坛指令
## 你的两个子指挥官:
1. **executor_tasks (任务执行官)**: 只处理任务类工具调用
2. **executor_forum (论坛执行官)**: 只处理论坛/指令帖相关工具调用
## 工作流程:
1. 理解用户要执行什么
2. 判断是否已具备足够信息
3. 调用相应工具
4. 汇总执行结果
5. 明确是否还需要下一步
## 响应要求:
- 明确说明已执行什么
- 工具结果要结构化、可读
- 成功时给出简洁确认
- 失败时说明卡点与下一步
- 如果信息不足,直接指出缺什么,不要假设
## 你的职责:
- 识别用户要推进的是任务操作还是论坛/指令操作
- 把请求交给最合适的执行子指挥官
- 汇总执行结果并给出下一步
"""
LIBRARIAN_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 Jarvis 的知识管理员,负责管理用户的私人知识库
你是 Jarvis 的知识管理员,负责先判断问题该由哪位知识子指挥官接手
## 你可以使用的工具:
- search_knowledge: 搜索知识库,返回相关文档片段
- get_knowledge_graph_context: 获取知识图谱上下文
- build_knowledge_graph: 从文档构建知识图谱
## 你的两个子指挥官:
1. **librarian_retrieval (检索问答官)**: 负责知识检索与证据综合
2. **librarian_graph (图谱沉淀官)**: 负责图谱上下文、关系串联与结构化沉淀
## 你的职责:
1. 理解用户关于知识的问题
2. 搜索相关知识
3. 综合多篇文档给出完整回答
4. 帮助用户整理和理解知识
## 工作流程:
1. 分析用户问题的关键概念
2. 搜索相关文档与图谱关系
3. 综合证据形成答案
4. 在证据不足时明确说明边界
## 响应要求:
- 回答要有依据,不靠猜测
- 引用时标注来源或依据范围
- 如果知识不足,诚实说明
- 可以补充必要背景,但不要离题
- 风格保持冷静、清楚、可信
- 判断当前需求更适合检索问答还是图谱沉淀
- 让回答建立在证据和结构之上
- 必要时收束子指挥官输出,给出最终回答
"""
@@ -200,28 +161,141 @@ ANALYST_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 Jarvis 的分析师,负责分析数据和工作状态。
## 你可以使用的工具:
- get_tasks: 获取任务列表,统计工作进度
- get_forum_posts: 获取论坛帖子,分析讨论趋势
- scan_forum_for_instructions: 检查待执行指令
- search_knowledge: 结合知识进行分析
## 你有两个子指挥官:
1. **analyst_progress (进度研判官)**: 汇总任务、论坛、指令执行状态,判断当前推进情况
2. **analyst_insights (洞察建议官)**: 提炼趋势、风险、机会点,并给出建议
## 你的职责:
1. 统计任务完成情况
2. 分析工作进度和趋势
3. 生成结构化报告
4. 识别潜在问题和风险
1. 判断当前问题更适合哪位子指挥官处理
2. 在需要时汇总子指挥官结果,给出面向用户的结论
3. 保持先结论后展开的表达方式
"""
## 工作流程:
1. 收集相关数据(任务、论坛、知识)
2. 识别模式、异常与趋势
3. 形成结论
4. 给出建议
PLANNER_SCOPE_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 planner 体系下的目标收束官,负责先把问题边界、目标、约束和成功标准说清楚。
## 你的重点:
- 收束问题定义
- 明确目标与限制条件
- 识别缺失信息
- 帮用户建立可以继续规划的前提
## 响应要求:
- 用数据说话,有数字、有结论
- 报告结构清晰,先结论后展开
- 明确风险、影响和建议
- 如果数据不完整,要说明分析置信度
- 可以有一丝冷幽默,但结论必须严谨
- 先给出你理解的目标
- 再列出关键约束或缺口
- 不要直接展开长步骤清单
"""
PLANNER_STEPS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 planner 体系下的步骤拆解官,负责把目标转成有顺序的执行路径。
## 你的重点:
- 拆解步骤
- 标注优先级与依赖
- 输出清晰的行动顺序
## 响应要求:
- 用编号列表
- 每步具体,不要空泛
- 必要时标注先后关系
"""
EXECUTOR_TASKS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 executor 体系下的任务执行官,只负责任务相关工具调用。
## 允许使用的工具:
- get_tasks
- create_task
- update_task_status
## 要求:
- 只处理任务类操作
- 明确已执行动作、结果与下一步
- 信息不足时直接指出缺口
"""
EXECUTOR_FORUM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 executor 体系下的论坛执行官,只负责论坛与指令帖相关工具调用。
## 允许使用的工具:
- get_forum_posts
- create_forum_post
- scan_forum_for_instructions
## 要求:
- 只处理论坛/指令类操作
- 结果要清楚说明是否执行成功
- 不要越权调用任务或知识工具
"""
LIBRARIAN_RETRIEVAL_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 librarian 体系下的检索问答官,负责从知识库与上下文中快速找到可靠信息。
## 允许使用的工具:
- search_knowledge
- hybrid_search
- get_knowledge_graph_context
## 要求:
- 优先检索与综合证据
- 证据不足时明确说明边界
- 以回答问题为主,不主动做图谱构建
"""
LIBRARIAN_GRAPH_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 librarian 体系下的图谱沉淀官,负责知识关系整理、图谱上下文与结构化沉淀。
## 允许使用的工具:
- get_knowledge_graph_context
- build_knowledge_graph
## 要求:
- 聚焦知识结构、关系串联与沉淀
- 明确说明构建/更新结果
- 不把自己变成泛检索问答器
"""
ANALYST_PROGRESS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 analyst 体系下的进度研判官,负责汇总当前任务、论坛与指令执行状态。
## 允许使用的工具:
- get_tasks
- get_forum_posts
- scan_forum_for_instructions
## 要求:
- 先结论后展开
- 重点说明进度、阻塞、待处理项
- 不做泛泛趋势空谈
"""
ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
你是 analyst 体系下的洞察建议官,负责从任务、论坛和知识线索里提炼趋势、风险与建议。
## 允许使用的工具:
- get_tasks
- get_forum_posts
- search_knowledge
- hybrid_search
## 要求:
- 先给结论与判断
- 再说明依据与建议
- 重点输出趋势、风险、机会点
"""

View File

@@ -55,6 +55,9 @@ class AgentState(TypedDict):
# Agent routing
current_agent: AgentRole
active_agents: list[AgentRole]
current_sub_commander: str | None
active_sub_commanders: list[str]
sub_commander_trace: list[dict]
# Task tracking
pending_tasks: list[dict]
@@ -93,6 +96,9 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
conversation_id=conversation_id,
current_agent=AgentRole.MASTER,
active_agents=[AgentRole.MASTER],
current_sub_commander=None,
active_sub_commanders=[],
sub_commander_trace=[],
pending_tasks=[],
completed_tasks=[],
tool_calls=[],

View File

@@ -5,18 +5,56 @@ from app.agents.tools.search import (
from app.agents.tools.task import get_tasks, create_task, update_task_status
from app.agents.tools.forum import get_forum_posts, create_forum_post, scan_forum_for_instructions
ALL_TOOLS = [
# 知识库工具
search_knowledge,
get_knowledge_graph_context,
build_knowledge_graph,
hybrid_search,
# 任务工具
TASK_TOOLS = [
get_tasks,
create_task,
update_task_status,
# 论坛工具
]
FORUM_TOOLS = [
get_forum_posts,
create_forum_post,
scan_forum_for_instructions,
]
KNOWLEDGE_RETRIEVAL_TOOLS = [
search_knowledge,
hybrid_search,
get_knowledge_graph_context,
]
KNOWLEDGE_GRAPH_TOOLS = [
get_knowledge_graph_context,
build_knowledge_graph,
]
ANALYST_PROGRESS_TOOLS = [
get_tasks,
get_forum_posts,
scan_forum_for_instructions,
]
ANALYST_INSIGHT_TOOLS = [
get_tasks,
get_forum_posts,
search_knowledge,
hybrid_search,
]
ALL_TOOLS = [
*KNOWLEDGE_RETRIEVAL_TOOLS,
build_knowledge_graph,
*TASK_TOOLS,
*FORUM_TOOLS,
]
SUB_COMMANDER_TOOLSETS = {
"planner_scope": [],
"planner_steps": [],
"executor_tasks": TASK_TOOLS,
"executor_forum": FORUM_TOOLS,
"librarian_retrieval": KNOWLEDGE_RETRIEVAL_TOOLS,
"librarian_graph": KNOWLEDGE_GRAPH_TOOLS,
"analyst_progress": ANALYST_PROGRESS_TOOLS,
"analyst_insights": ANALYST_INSIGHT_TOOLS,
}

View File

@@ -9,13 +9,17 @@ from app.schemas.agent import AgentCreate, AgentOut, AgentStats, AgentConfigUpda
router = APIRouter(prefix="/api/agents", tags=["Agent"])
# 运行时调用统计(内存中,非持久化)
_agent_call_counts: dict[str, int] = {}
_agent_current_tasks: dict[str, str | None] = {}
_agent_statuses: dict[str, str] = {}
# 默认 Agent 角色列表
DEFAULT_AGENT_ROLES = ["master", "planner", "executor", "librarian", "analyst"]
SUB_COMMANDERS_BY_ROLE = {
"planner": ["planner_scope", "planner_steps"],
"executor": ["executor_tasks", "executor_forum"],
"librarian": ["librarian_retrieval", "librarian_graph"],
"analyst": ["analyst_progress", "analyst_insights"],
}
def record_agent_call(agent_id: str):
@@ -31,6 +35,15 @@ def set_agent_status(agent_id: str, status: str):
_agent_statuses[agent_id] = status
def _build_agent_stats(agent_id: str) -> AgentStats:
return AgentStats(
agent_id=agent_id,
call_count=_agent_call_counts.get(agent_id, 0),
current_task=_agent_current_tasks.get(agent_id),
status=_agent_statuses.get(agent_id, "idle"),
)
@router.get("", response_model=list[AgentOut])
async def list_agents(
current_user: User = Depends(get_current_user),
@@ -42,32 +55,35 @@ async def list_agents(
return result.scalars().all()
# ———— 运行时统计(必须在 /{agent_id} 之前)————
@router.get("/stats", response_model=list[AgentStats])
async def get_agent_stats(
current_user: User = Depends(get_current_user),
):
"""
获取各 Agent 的运行时统计(调用次数、当前任务、状态)
"""
stats = []
return [_build_agent_stats(role) for role in DEFAULT_AGENT_ROLES]
@router.get("/stats/hierarchy")
async def get_agent_hierarchy_stats(
current_user: User = Depends(get_current_user),
):
main_agents = []
for role in DEFAULT_AGENT_ROLES:
stats.append(AgentStats(
agent_id=role,
call_count=_agent_call_counts.get(role, 0),
current_task=_agent_current_tasks.get(role),
status=_agent_statuses.get(role, "idle"),
))
return stats
if role == "master":
continue
node = _build_agent_stats(role).model_dump()
node["sub_commanders"] = [
_build_agent_stats(sub_id).model_dump()
for sub_id in SUB_COMMANDERS_BY_ROLE.get(role, [])
]
main_agents.append(node)
return {"main_agents": main_agents}
# ———— 配置管理(必须在 /{agent_id} 之前)————
@router.get("/config/{agent_id}", response_model=AgentConfigOut)
async def get_agent_config(
agent_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取单个 Agent 完整配置"""
result = await db.execute(select(Agent).where(Agent.role == agent_id))
agent = result.scalar_one_or_none()
@@ -84,8 +100,13 @@ async def get_agent_config(
raise HTTPException(status_code=404, detail="Agent 不存在")
name, desc, prompt = defaults[agent_id]
return AgentConfigOut(
id=agent_id, name=name, role=agent_id,
description=desc, system_prompt=prompt, enabled=True, is_active=True,
id=agent_id,
name=name,
role=agent_id,
description=desc,
system_prompt=prompt,
enabled=True,
is_active=True,
)
return AgentConfigOut(
id=agent.role,
@@ -105,7 +126,6 @@ async def update_agent_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新 Agent 配置(名称、描述、提示词、启用状态)"""
result = await db.execute(select(Agent).where(Agent.role == agent_id))
agent = result.scalar_one_or_none()
@@ -163,78 +183,3 @@ async def get_agent(
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
return agent
# ———— 配置管理 ————
@router.get("/config/{agent_id}", response_model=AgentConfigOut)
async def get_agent_config(
agent_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取单个 Agent 完整配置"""
result = await db.execute(select(Agent).where(Agent.role == agent_id))
agent = result.scalar_one_or_none()
if not agent:
# 如果数据库中没有,返回默认配置
from app.agents.prompts import MASTER_SYSTEM_PROMPT, PLANNER_SYSTEM_PROMPT, EXECUTOR_SYSTEM_PROMPT, LIBRARIAN_SYSTEM_PROMPT, ANALYST_SYSTEM_PROMPT
defaults = {
"master": ("JARVIS", "主控制核心", MASTER_SYSTEM_PROMPT),
"planner": ("PLANNER", "规划专家", PLANNER_SYSTEM_PROMPT),
"executor": ("EXECUTOR", "执行专家", EXECUTOR_SYSTEM_PROMPT),
"librarian": ("LIBRARIAN", "知识管理员", LIBRARIAN_SYSTEM_PROMPT),
"analyst": ("ANALYST", "数据分析师", ANALYST_SYSTEM_PROMPT),
}
if agent_id not in defaults:
raise HTTPException(status_code=404, detail="Agent 不存在")
name, desc, prompt = defaults[agent_id]
return AgentConfigOut(
id=agent_id, name=name, role=agent_id,
description=desc, system_prompt=prompt, enabled=True, is_active=True,
)
return AgentConfigOut(
id=agent.role,
name=agent.name,
role=agent.role,
description=agent.description,
system_prompt=agent.system_prompt,
enabled=agent.is_active,
is_active=agent.is_active,
)
@router.put("/config/{agent_id}", response_model=AgentConfigOut)
async def update_agent_config(
agent_id: str,
data: AgentConfigUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新 Agent 配置(名称、描述、提示词、启用状态)"""
result = await db.execute(select(Agent).where(Agent.role == agent_id))
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
if data.name is not None:
agent.name = data.name
if data.description is not None:
agent.description = data.description
if data.system_prompt is not None:
agent.system_prompt = data.system_prompt
if data.enabled is not None:
agent.is_active = data.enabled
_agent_statuses[agent_id] = "disabled" if not data.enabled else "idle"
await db.commit()
await db.refresh(agent)
return AgentConfigOut(
id=agent.role,
name=agent.name,
role=agent.role,
description=agent.description,
system_prompt=agent.system_prompt,
enabled=agent.is_active,
is_active=agent.is_active,
)

View File

@@ -225,6 +225,9 @@ class AgentService:
"conversation_id": conversation_id,
"current_agent": "master",
"active_agents": ["master"],
"current_sub_commander": None,
"active_sub_commanders": [],
"sub_commander_trace": [],
"pending_tasks": [],
"completed_tasks": [],
"tool_calls": [],

View File

@@ -3,6 +3,8 @@
支持多种文档格式 + LlamaIndex 智能分块
"""
from pathlib import Path
import tempfile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import UploadFile
@@ -380,7 +382,42 @@ class DocumentService:
if hasattr(mineru, "parse_to_markdown"):
return mineru.parse_to_markdown(file_path)
raise ValueError("PDF 解析失败: MinerU 不支持当前接口")
try:
from mineru.cli.common import do_parse, read_fn
from mineru.utils.enum_class import MakeMode
except Exception as error:
raise ValueError(
"PDF 解析失败: 当前安装的 MinerU 版本接口不兼容,请确认支持 to_markdown / parse_to_markdown或提供 cli.common.do_parse 能力"
) from error
with tempfile.TemporaryDirectory(prefix="mineru-") as output_dir:
pdf_name = Path(file_path).stem
pdf_bytes = read_fn(Path(file_path))
try:
do_parse(
output_dir,
[pdf_name],
[pdf_bytes],
["zh"],
f_draw_layout_bbox=False,
f_draw_span_bbox=False,
f_dump_md=True,
f_dump_middle_json=False,
f_dump_model_output=False,
f_dump_orig_pdf=False,
f_dump_content_list=False,
f_make_md_mode=MakeMode.MM_MD,
)
except ModuleNotFoundError as error:
dependency = getattr(error, "name", None) or str(error).split("'")[-2] if "'" in str(error) else str(error)
raise ValueError(f"PDF 解析依赖缺失: MinerU 运行时依赖 {dependency}") from error
markdown_path = Path(output_dir) / pdf_name / "pipeline" / f"{pdf_name}.md"
if markdown_path.exists():
return markdown_path.read_text(encoding="utf-8")
raise ValueError(
"PDF 解析失败: 当前安装的 MinerU 版本接口不兼容,请确认支持 to_markdown / parse_to_markdown或提供 cli.common.do_parse 能力"
)
async def _parse_pdf(self, file_path: str) -> ParsedDocument:
markdown = await self._parse_pdf_with_mineru(file_path)

View File

@@ -1,6 +1,7 @@
import json
from io import BytesIO
import builtins
from pathlib import Path
import sys
import types
@@ -351,6 +352,73 @@ async def test_get_document_content_returns_normalized_pdf_content(document_test
assert content == '# PDF Title\n\nNormalized pdf body.'
@pytest.mark.asyncio
async def test_upload_document_uses_mineru_cli_do_parse_fallback_for_pdf(document_test_env, monkeypatch, tmp_path):
session, user = document_test_env
service = DocumentService(session)
fake_mineru = types.SimpleNamespace()
fake_common = types.SimpleNamespace()
def fake_do_parse(output_dir, pdf_file_names, pdf_bytes_list, p_lang_list, **kwargs):
output_path = Path(output_dir) / pdf_file_names[0] / 'pipeline'
output_path.mkdir(parents=True, exist_ok=True)
(output_path / f'{pdf_file_names[0]}.md').write_text('# PDF Title\n\nCLI fallback content.', encoding='utf-8')
fake_common.do_parse = fake_do_parse
monkeypatch.setitem(sys.modules, 'mineru', fake_mineru)
monkeypatch.setitem(sys.modules, 'mineru.cli', types.SimpleNamespace(common=fake_common))
monkeypatch.setitem(sys.modules, 'mineru.cli.common', fake_common)
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(tmp_path / 'uploads'))
upload = UploadFile(filename='fallback.pdf', file=BytesIO(b'%PDF-1.4 fake'))
document = await service.upload_document(user.id, upload)
assert document.normalized_format == 'structured_markdown'
assert '# PDF Title' in document.normalized_content
assert 'CLI fallback content.' in document.normalized_content
@pytest.mark.asyncio
async def test_upload_document_raises_clear_error_when_mineru_cli_runtime_dependency_is_missing(document_test_env, monkeypatch):
session, user = document_test_env
service = DocumentService(session)
fake_mineru = types.SimpleNamespace()
fake_common = types.SimpleNamespace()
fake_enum_class = types.SimpleNamespace(MakeMode=types.SimpleNamespace(MM_MD='mm_markdown'))
def fake_do_parse(*args, **kwargs):
raise ModuleNotFoundError("No module named 'torch'")
fake_common.do_parse = fake_do_parse
fake_common.read_fn = lambda path: b'%PDF-1.4 fake'
monkeypatch.setitem(sys.modules, 'mineru', fake_mineru)
monkeypatch.setitem(sys.modules, 'mineru.cli', types.SimpleNamespace(common=fake_common))
monkeypatch.setitem(sys.modules, 'mineru.cli.common', fake_common)
monkeypatch.setitem(sys.modules, 'mineru.utils', types.SimpleNamespace(enum_class=fake_enum_class))
monkeypatch.setitem(sys.modules, 'mineru.utils.enum_class', fake_enum_class)
upload = UploadFile(filename='runtime-missing.pdf', file=BytesIO(b'%PDF-1.4 fake'))
with pytest.raises(ValueError, match="PDF 解析依赖缺失: MinerU 运行时依赖 torch"):
await service.upload_document(user.id, upload)
@pytest.mark.asyncio
async def test_upload_document_raises_clear_error_when_mineru_interface_is_unsupported(document_test_env, monkeypatch):
session, user = document_test_env
service = DocumentService(session)
fake_mineru = types.SimpleNamespace()
monkeypatch.setitem(sys.modules, 'mineru', fake_mineru)
upload = UploadFile(filename='unsupported.pdf', file=BytesIO(b'%PDF-1.4 fake'))
with pytest.raises(ValueError, match='PDF 解析失败: 当前安装的 MinerU 版本接口不兼容'):
await service.upload_document(user.id, upload)
@pytest.mark.asyncio
async def test_upload_document_raises_clear_error_when_pdf_dependency_is_missing(document_test_env, monkeypatch):
session, user = document_test_env

View File

@@ -7,6 +7,14 @@ export interface AgentStats {
status: 'active' | 'idle' | 'disabled'
}
export interface AgentHierarchyStatsNode extends AgentStats {
sub_commanders: AgentStats[]
}
export interface AgentHierarchyStats {
main_agents: AgentHierarchyStatsNode[]
}
export interface AgentConfig {
id: string
name: string
@@ -22,6 +30,11 @@ export const agentApi = {
return res.data
},
async getHierarchyStats(): Promise<AgentHierarchyStats> {
const res = await api.get('/api/agents/stats/hierarchy')
return res.data
},
async getConfig(id: string): Promise<AgentConfig> {
const res = await api.get(`/api/agents/config/${id}`)
return res.data

View File

@@ -21,7 +21,7 @@ export interface NavItem {
export const navItems: NavItem[] = [
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
{ name: '智能链路', path: '/agents', icon: Bot },
{ name: '奥创中心', path: '/agents', icon: Bot },
{ name: '技能中心', path: '/skills', icon: Star },
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
{ name: '知识大脑', path: '/brain', icon: Network },

View File

@@ -181,7 +181,7 @@ describe('useKnowledgeView chunk editing', () => {
expect(view.documents.value[0].id).toBe('doc-3')
})
it('enters a newly created folder so refresh keeps uploaded documents visible', async () => {
it('stays on the current listing after creating a folder and shows the new folder', async () => {
const createdFolder = {
id: 'folder-new',
name: '新文件夹',
@@ -190,17 +190,6 @@ describe('useKnowledgeView chunk editing', () => {
created_at: '2026-03-22T00:00:00Z',
updated_at: '2026-03-22T00:00:00Z',
}
const uploadedDocument = {
id: 'doc-new',
title: 'Uploaded after create',
filename: 'uploaded.md',
file_type: 'md',
file_size: 256,
chunk_count: 1,
is_indexed: true,
folder_id: 'folder-new',
created_at: '2026-03-22T00:00:00Z',
}
mocks.folderCreate.mockResolvedValue({ data: createdFolder })
mocks.folderGetTree.mockResolvedValue({
@@ -213,36 +202,18 @@ describe('useKnowledgeView chunk editing', () => {
},
],
})
mocks.documentList
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [uploadedDocument] })
.mockResolvedValue({ data: [uploadedDocument] })
mocks.documentUpload.mockResolvedValue({
data: {
id: 'doc-new',
title: 'Uploaded after create',
chunk_count: 1,
status: '上传成功,正在索引...',
ingestion_status: 'ready',
},
})
const view = useKnowledgeView()
view.showNewFolderDialog.value = true
view.newFolderName.value = '新文件夹'
await view.createFolder()
expect(view.currentFolderId.value).toBe('folder-new')
expect(routeQuery.folder_id).toBe('folder-new')
expect(storage.get('knowledge.currentFolderId')).toBe('folder-new')
const file = new File(['hello'], 'uploaded.md', { type: 'text/markdown' })
const event = { target: { files: [file], value: 'uploaded.md' } } as unknown as Event
await view.handleUpload(event)
expect(mocks.documentUpload).toHaveBeenCalledWith(file, 'folder-new')
expect(view.documents.value).toHaveLength(1)
expect(view.documents.value[0].folder_id).toBe('folder-new')
expect(view.currentFolderId.value).toBe(null)
expect(routeQuery.folder_id).toBeUndefined()
expect(storage.get('knowledge.currentFolderId')).toBeUndefined()
expect(view.showNewFolderDialog.value).toBe(false)
expect(view.visibleFolders.value).toHaveLength(1)
expect(view.visibleFolders.value[0].id).toBe('folder-new')
})
it('loads documents at the root view instead of clearing the list', async () => {

View File

@@ -298,13 +298,12 @@ export function useKnowledgeView() {
if (!newFolderName.value.trim()) return
try {
const response = await folderApi.create({
await folderApi.create({
name: newFolderName.value.trim(),
parent_id: newFolderParentId.value,
})
await loadFolders()
showNewFolderDialog.value = false
await goToFolder(response.data.id)
} catch (error) {
console.error('创建文件夹失败:', error)
}

168
setup.bat
View File

@@ -1,168 +0,0 @@
@echo off
chcp 65001 >nul
title Jarvis 配置向导
echo ==========================================
echo Jarvis 个人 AI 助理 - 配置向导
echo ==========================================
echo.
REM --- 选择 LLM 提供商 ---
echo 请选择 LLM 提供商:
echo [1] OpenAI (GPT-4o) - 默认推荐
echo [2] DeepSeek (deepseek-chat) - 性价比高
echo [3] Claude (Anthropic) - 强大稳定
echo [4] Ollama (本地模型) - 完全免费
echo.
set /p PROVIDER_CHOICE=请选择 (1-4), 默认1:
if "%PROVIDER_CHOICE%"=="" set PROVIDER_CHOICE=1
if "%PROVIDER_CHOICE%"=="1" goto :openai
if "%PROVIDER_CHOICE%"=="2" goto :deepseek
if "%PROVIDER_CHOICE%"=="3" goto :claude
if "%PROVIDER_CHOICE%"=="4" goto :ollama
goto :openai
:openai
echo.
echo [选择] OpenAI (GPT-4o)
echo.
set /p OPENAI_API_KEY=请输入 OpenAI API Key (sk-...):
if "%OPENAI_API_KEY%"=="" (
echo [错误] API Key 不能为空
pause
exit /b 1
)
goto :write_env
:deepseek
echo.
echo [选择] DeepSeek
echo.
set /p OPENAI_API_KEY=请输入 DeepSeek API Key:
if "%OPENAI_API_KEY%"=="" (
echo [错误] API Key 不能为空
pause
exit /b 1
)
goto :write_env
:claude
echo.
echo [选择] Claude (Anthropic)
echo.
set /p ANTHROPIC_API_KEY=请输入 Anthropic API Key (sk-ant-...):
if "%ANTHROPIC_API_KEY%"=="" (
echo [错误] API Key 不能为空
pause
exit /b 1
)
goto :write_env
:ollama
echo.
echo [选择] Ollama (本地模型)
echo 请确保 Ollama 已启动 (ollama serve)
echo.
set /p OLLAMA_MODEL_IN=请输入模型名 (留空默认 llama3):
if "%OLLAMA_MODEL_IN%"=="" set OLLAMA_MODEL=llama3
goto :write_env
:write_env
echo.
echo [配置] 写入 backend\.env...
(
echo # =============================================
echo # Jarvis 后端配置
echo # =============================================
echo.
echo APP_NAME=Jarvis
echo APP_VERSION=0.1.0
echo DEBUG=true
echo SECRET_KEY=jarvis-secret-key-change-in-production
echo.
echo CORS_ORIGINS=http://localhost:5173,http://localhost:3000
echo.
echo # LLM 配置
) > backend\.env
if "%PROVIDER_CHOICE%"=="1" (
(
echo LLM_PROVIDER=openai
echo OPENAI_API_KEY=%OPENAI_API_KEY%
echo OPENAI_MODEL=gpt-4o
echo OPENAI_BASE_URL=https://api.openai.com/v1
) >> backend\.env
) else if "%PROVIDER_CHOICE%"=="2" (
(
echo LLM_PROVIDER=deepseek
echo OPENAI_API_KEY=%OPENAI_API_KEY%
echo OPENAI_BASE_URL=https://api.deepseek.com/v1
) >> backend\.env
) else if "%PROVIDER_CHOICE%"=="3" (
(
echo LLM_PROVIDER=claude
echo ANTHROPIC_API_KEY=%ANTHROPIC_API_KEY%
echo CLAUDE_MODEL=claude-sonnet-4-20250514
) >> backend\.env
) else (
(
echo LLM_PROVIDER=ollama
echo OLLAMA_BASE_URL=http://localhost:11434
echo OLLAMA_MODEL=%OLLAMA_MODEL%
) >> backend\.env
)
(
echo.
echo # 数据库
echo DATABASE_URL=sqlite+aiosqlite:///./data/jarvis.db
echo.
echo # ChromaDB
echo CHROMA_PERSIST_DIR=./data/chroma
echo.
echo # JWT
echo ACCESS_TOKEN_EXPIRE_MINUTES=1440
echo.
echo # 上传
echo UPLOAD_DIR=./data/uploads
echo MAX_UPLOAD_SIZE=52428800
echo.
echo # 定时任务
echo SCHEDULER_ENABLED=true
echo DAILY_PLAN_TIME=00:00
echo FORUM_SCAN_INTERVAL_MINUTES=30
echo.
echo DATA_DIR=./data
) >> backend\.env
echo [OK] 配置完成
REM --- 检查 uv ---
echo.
echo [检查] 验证环境...
where uv >nul 2>&1
if %errorlevel% neq 0 (
echo [警告] 未找到 uv请运行: pip install uv
echo 或访问: https://github.com/astral-sh/uv
) else (
echo [OK] uv 已安装
)
where npm >nul 2>&1
if %errorlevel% neq 0 (
echo [警告] 未找到 npm请安装 Node.js: https://nodejs.org
) else (
echo [OK] npm 已安装
)
echo.
echo ==========================================
echo 配置完成!
echo.
echo LLM: %PROVIDER_CHOICE%
echo.
echo 下一步: 双击 start.bat 启动服务
echo ==========================================
pause

115
start.bat
View File

@@ -1,115 +0,0 @@
@echo off
setlocal
chcp 65001 >nul
title Jarvis Start
set "ROOT=%~dp0"
set "BACKEND_DIR=%ROOT%backend"
set "FRONTEND_DIR=%ROOT%frontend"
set "LOG_DIR=%ROOT%logs"
set "PROJECT_ENV=%ROOT%.env"
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
echo ==========================================
echo Jarvis - Quick Start
echo ==========================================
echo.
where uv >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] uv was not found. Install it first:
echo pip install uv
echo.
pause
exit /b 1
)
where npm >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] npm was not found. Install Node.js first.
echo.
pause
exit /b 1
)
if not exist "%PROJECT_ENV%" (
echo [INFO] .env was not found in the project root.
echo [INFO] Create it before first run.
echo.
)
echo [1/4] Install backend dependencies...
cd /d "%BACKEND_DIR%"
uv sync --quiet
if %errorlevel% neq 0 (
echo [ERROR] Failed to install backend dependencies.
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
echo [2/4] Install frontend dependencies...
cd /d "%FRONTEND_DIR%"
call npm install >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Failed to install frontend dependencies.
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
set "BACKEND_HOST=127.0.0.1"
set "BACKEND_PORT="
if exist "%PROJECT_ENV%" (
for /f "usebackq tokens=1,* delims==" %%A in ("%PROJECT_ENV%") do (
if /I "%%A"=="HOST" set "BACKEND_HOST=%%B"
if /I "%%A"=="PORT" set "BACKEND_PORT=%%B"
)
)
if "%BACKEND_PORT%"=="" (
echo [ERROR] PORT was not found in .env.
echo [ERROR] Set PORT in the project root .env and run start.bat again.
pause
exit /b 1
)
set "FRONTEND_API_URL=http://%BACKEND_HOST%:%BACKEND_PORT%"
> "%FRONTEND_DIR%\.env.local" echo VITE_API_URL=%FRONTEND_API_URL%
set "BACKEND_LOG=%LOG_DIR%\backend-start.log"
set "FRONTEND_LOG=%LOG_DIR%\frontend-start.log"
echo.
echo [3/4] Start backend in background on %BACKEND_HOST%:%BACKEND_PORT%...
powershell -NoProfile -WindowStyle Hidden -Command "$wd='%BACKEND_DIR%'; $out='%BACKEND_LOG%'; Start-Process powershell -WindowStyle Hidden -WorkingDirectory $wd -ArgumentList '-NoProfile','-Command',('uv run uvicorn app.main:app --reload --host %BACKEND_HOST% --port %BACKEND_PORT% *>> ''{0}''' -f $out)"
if %errorlevel% neq 0 (
echo [ERROR] Failed to start backend.
pause
exit /b 1
)
echo Waiting for backend...
ping 127.0.0.1 -n 6 >nul
echo.
echo [4/4] Start frontend in background on port 5173...
powershell -NoProfile -WindowStyle Hidden -Command "$wd='%FRONTEND_DIR%'; $out='%FRONTEND_LOG%'; Start-Process powershell -WindowStyle Hidden -WorkingDirectory $wd -ArgumentList '-NoProfile','-Command',('npm run dev *>> ''{0}''' -f $out)"
if %errorlevel% neq 0 (
echo [ERROR] Failed to start frontend.
pause
exit /b 1
)
echo.
echo ==========================================
echo Started
echo.
echo Backend: http://%BACKEND_HOST%:%BACKEND_PORT%
echo Frontend: http://localhost:5173
echo API docs: http://%BACKEND_HOST%:%BACKEND_PORT%/docs
echo.
echo Logs:
echo - %BACKEND_LOG%
echo - %FRONTEND_LOG%
echo ==========================================
echo.
pause