diff --git a/app/actions/batch.py b/app/actions/batch.py index df66bbc..1d3781f 100644 --- a/app/actions/batch.py +++ b/app/actions/batch.py @@ -7,7 +7,7 @@ from copy import deepcopy from app.actions.registry import apply_action from app.actions.update import fit_existing_path -from app.resources.material_fields import check_update_field +from app.resources.material_fields import check_update_field, find_material from app.schemas.actions import Action, AddAction, UpdateAction from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.component_resource import ComponentCatalog @@ -39,18 +39,8 @@ def merge_config(defaults: dict, overrides: dict) -> dict: def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction: - """用物料默认配置补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data""" - material = next( - (item for item in catalog.components if item.key == action.component_key), None - ) - if material is None: - raise ValueError( - f"物料 {action.component_key} 不存在,可用物料用 list_components 查看" - ) - if material.type != action.node.type: - raise ValueError( - f"物料 {material.key} 的类型是 {material.type},node.type 必须与之一致" - ) + """按 node.type 找物料补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data""" + material = find_material(catalog, action.node.type) if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): raise ValueError(DATA_BINDING_ERROR) overrides = action.node.model_dump(mode="json", exclude_unset=True) diff --git a/app/clients/mcp.py b/app/clients/mcp.py index 6ddfb33..eb70446 100644 --- a/app/clients/mcp.py +++ b/app/clients/mcp.py @@ -8,6 +8,7 @@ import asyncio import json import os +import re import socket import subprocess import sys @@ -29,18 +30,37 @@ CONFIG_PATH = PROJECT_ROOT / "mcp_servers.json" # 物料服务返回的目录版本须与主进程一致,否则模型查到的字段和校验依据不同 MATERIAL_SERVER = "materials" LOCAL_HOSTS = {"localhost", "127.0.0.1"} +# 配置中引用环境变量的写法 +ENV_REFERENCE = re.compile(r"\$\{(\w+)\}") class MCPServiceError(RuntimeError): """MCP 配置错误、服务不可用,或物料目录版本不一致""" +def _expand_env(value): + """只展开字符串值里的 ${NAME};变量未设置时报错,不把字面量当成地址或密钥""" + if isinstance(value, dict): + return {key: _expand_env(item) for key, item in value.items()} + if isinstance(value, list): + return [_expand_env(item) for item in value] + if not isinstance(value, str): + return value + + def lookup(match: re.Match) -> str: + if (found := os.environ.get(match[1])) is None: + raise MCPServiceError(f"MCP 服务配置引用的环境变量 {match[1]} 未设置") + return found + + return ENV_REFERENCE.sub(lookup, value) + + def load_mcp_config(path: Path = CONFIG_PATH) -> dict[str, dict]: """读取服务列表并展开环境变量;文件缺失或格式不对时报错""" if not path.exists(): raise MCPServiceError(f"未找到 MCP 服务配置: {path}") try: - config = json.loads(os.path.expandvars(path.read_text(encoding="utf-8"))) + config = _expand_env(json.loads(path.read_text(encoding="utf-8"))) except json.JSONDecodeError as error: raise MCPServiceError(f"MCP 服务配置不是合法 JSON: {error}") from error if not isinstance(config, dict) or not all( diff --git a/app/main.py b/app/main.py index 7338cd3..99e5124 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.core.logging import setup_logging @asynccontextmanager async def lifespan(application: FastAPI): setup_logging() + from app.agent.screen_agent import get_screen_agent from app.clients.decision import close_decision_engine from app.clients.mcp import start_mcp_servers, stop_mcp_servers from app.clients.user_center import close_user_center @@ -35,6 +36,11 @@ async def lifespan(application: FastAPI): await warm_material_cache(persistence.db) # 拉起 mcp_servers.json 中由应用管理的服务;单个服务失败只记录,用到它的请求执行时报错 mcp_processes = await start_mcp_servers() + # 提前拉取 MCP 工具并组装 agent,问题在启动时暴露;失败只记录,首个请求会重试 + try: + await get_screen_agent() + except Exception as error: + logger.opt(exception=error).error("screen agent 预先组装失败") application.state.agent_service = AgentService( build_graph(persistence.checkpointer), persistence.store ) diff --git a/app/observability/tracing.py b/app/observability/tracing.py index 8eba04b..113e34b 100644 --- a/app/observability/tracing.py +++ b/app/observability/tracing.py @@ -55,26 +55,6 @@ def flush_tracing() -> None: logger.opt(exception=exc).warning("Langfuse flush 失败") -async def ainvoke_screen( - graph: Any, - payload: Any, - *, - thread_id: str, - session_id: str | None = None, - user_id: int | None = None, -) -> Any: - """每轮调用记录一个根 span,返回、等待用户或异常时结束。 - - session_id 由调用方在首次与恢复时一致传入(通常为 conversation_id)。 - 未传时始终使用 thread_id,不从仅首次请求才有的 payload 推断。 - user_id 为用户中心的用户 ID,接口调用时传入,本地脚本不传。 - """ - with _screen_trace(payload, thread_id, session_id, user_id) as (config, root): - result = await graph.ainvoke(payload, config=config) - _finish_trace(root, result) - return result - - async def astream_screen(graph, payload, *, thread_id, session_id=None, user_id=None): """同一轮流保持同一个追踪上下文,关闭流时结束追踪""" from contextlib import aclosing diff --git a/app/processor/graph.py b/app/processor/graph.py index e5b77ec..18372d9 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -81,7 +81,3 @@ def build_graph(checkpointer=None): if checkpointer is None: checkpointer = InMemorySaver(serde=build_checkpoint_serde()) return workflow.compile(checkpointer=checkpointer) - - -# 模块加载时创建共享图实例,复用该实例的调用也共用其检查点存储 -query_app = build_graph() diff --git a/app/processor/nodes/screen_agent_node.py b/app/processor/nodes/screen_agent_node.py index 01c971f..d150479 100644 --- a/app/processor/nodes/screen_agent_node.py +++ b/app/processor/nodes/screen_agent_node.py @@ -62,8 +62,6 @@ class ScreenAgentNode(BaseNode): ] }, context=ScreenAgentContext(screen=context.screen, catalog=catalog), - # 调用次数由 ModelCallLimitMiddleware 限制,这里只防止异常循环 - config={"recursion_limit": 4 * self.config.max_agent_steps + 10}, ) if result.get("structured_response") is None: last = result["messages"][-1] diff --git a/app/resources/material_fields.py b/app/resources/material_fields.py index 85af7ab..3056d88 100644 --- a/app/resources/material_fields.py +++ b/app/resources/material_fields.py @@ -105,7 +105,7 @@ def list_components(catalog: ComponentCatalog) -> dict: def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict: - """指定类型的物料默认配置,同一类型的多个预设全部返回""" + """指定类型的物料默认配置""" known = {item.type for item in catalog.components} if unknown := [item for item in component_types if item not in known]: raise ValueError( diff --git a/app/schemas/actions/add.py b/app/schemas/actions/add.py index 6aef149..a4b06b5 100644 --- a/app/schemas/actions/add.py +++ b/app/schemas/actions/add.py @@ -5,7 +5,6 @@ from typing import Literal from pydantic import Field from app.schemas.actions.base import ActionModel -from app.schemas.base import NonBlankString from app.schemas.screen import ScreenNode @@ -13,13 +12,10 @@ class AddAction(ActionModel): """按物料新增组件""" type: Literal["add"] = "add" - component_key: NonBlankString = Field( - description="物料目录编号,即 list_components 返回的 key" - ) node: ScreenNode = Field( description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" - "后端按物料默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" - "id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 必须与物料一致" + "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" + "id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 用 list_components 返回的 type" ) sample_data: bool = Field( default=False, diff --git a/app/utils/__init__.py b/app/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/main.py b/main.py deleted file mode 100644 index eb389a0..0000000 --- a/main.py +++ /dev/null @@ -1,16 +0,0 @@ -# 这是一个示例 Python 脚本。 - -# 按 Shift+F10 执行或将其替换为您的代码。 -# 按 双击 Shift 在所有地方搜索类、文件、工具窗口、操作和设置。 - - -def print_hi(name): - # 在下面的代码行中使用断点来调试脚本。 - print(f'Hi, {name}') # 按 Ctrl+F8 切换断点。 - - -# 按装订区域中的绿色按钮以运行脚本。 -if __name__ == '__main__': - print_hi('PyCharm') - -# 访问 https://www.jetbrains.com/help/pycharm/ 获取 PyCharm 帮助 diff --git a/mcp_servers/material_server.py b/mcp_servers/material_server.py index ecceee7..04c7189 100644 --- a/mcp_servers/material_server.py +++ b/mcp_servers/material_server.py @@ -46,10 +46,7 @@ def _require_catalog() -> ComponentCatalog: @mcp.tool() async def list_components() -> dict[str, Any]: - """列出可以新增的组件:key、type、名称、用途和默认宽高 - - 新建大屏、新增组件或更换类型时,先从这里选类型,再用 get_component_materials 查看默认配置 - """ + """列出可以新增的组件:key、type、名称、用途和默认宽高""" return material_fields.list_components(_require_catalog()) @@ -60,10 +57,7 @@ async def get_component_materials( Field(description="要使用的物料类型,可一次传多个", min_length=1), ], ) -> dict[str, Any]: - """获取物料的完整默认配置(schema)和使用说明 - - 新增组件前先查看所选类型的默认配置,只覆盖需要修改的字段,不从零编写 props - """ + """获取物料的完整默认配置(schema)和使用说明""" return material_fields.get_materials(_require_catalog(), component_types) diff --git a/skills/chart-replace/SKILL.md b/skills/chart-replace/SKILL.md index 135e449..b825728 100644 --- a/skills/chart-replace/SKILL.md +++ b/skills/chart-replace/SKILL.md @@ -11,7 +11,7 @@ description: 把已有组件换成另一种组件类型,例如"把折线图换 2. 用 list_components 选出用户要的新类型,用 get_component_materials 查看它的默认配置 3. 在同一批动作里先 delete 原组件,再 add 新组件: - node.id 沿用原组件 ID,layout 沿用原组件的位置和尺寸(用户另有要求除外) - - component_key 选新类型对应的物料 + - node.type 填新类型 4. 用 apply_actions 提交 ## 数据迁移 diff --git a/skills/screen-design/SKILL.md b/skills/screen-design/SKILL.md index 96d0de0..c78e55d 100644 --- a/skills/screen-design/SKILL.md +++ b/skills/screen-design/SKILL.md @@ -8,7 +8,7 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、 ## 选组件 1. 用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型 -2. 用 get_component_materials 查看所选类型的默认配置(可一次查多个类型),新组件以默认配置为基础,只写需要改的字段,例如标题 +2. 用 get_component_materials 查看所选类型的默认配置 3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题 ## 布局 @@ -22,7 +22,3 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、 - 重新布局:用 move 和 resize 调整已有组件,不删除、不重建 - 统一风格:先用 get_nodes 读取组件配置,用 search_fields 找到颜色等字段,再逐个修改;画布背景用 update_canvas - -## 提交 - -本次的所有新增和调整放在同一批,用 apply_actions 提交 diff --git a/skills/screen-edit/SKILL.md b/skills/screen-edit/SKILL.md index f6e873d..10b28a6 100644 --- a/skills/screen-edit/SKILL.md +++ b/skills/screen-edit/SKILL.md @@ -23,7 +23,3 @@ description: 修改大屏上已有的组件或画布:改标题、文字、颜 - 相对变化("放大一倍""往右移 100")依据大屏摘要中的当前位置和尺寸换算 - "放到正中间"等按画布尺寸计算目标位置 - -## 提交 - -本次的所有修改放在同一批,用 apply_actions 提交