bugfix:修复一些bug

This commit is contained in:
2026-10-02 02:36:11 -07:00
parent 68588d1f92
commit 5b1e6c5654
14 changed files with 37 additions and 81 deletions
+3 -13
View File
@@ -7,7 +7,7 @@ from copy import deepcopy
from app.actions.registry import apply_action from app.actions.registry import apply_action
from app.actions.update import fit_existing_path 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.actions import Action, AddAction, UpdateAction
from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED
from app.schemas.component_resource import ComponentCatalog 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: def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction:
"""用物料默认配置补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data""" """按 node.type 找物料补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data"""
material = next( material = find_material(catalog, action.node.type)
(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 必须与之一致"
)
if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"):
raise ValueError(DATA_BINDING_ERROR) raise ValueError(DATA_BINDING_ERROR)
overrides = action.node.model_dump(mode="json", exclude_unset=True) overrides = action.node.model_dump(mode="json", exclude_unset=True)
+21 -1
View File
@@ -8,6 +8,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -29,18 +30,37 @@ CONFIG_PATH = PROJECT_ROOT / "mcp_servers.json"
# 物料服务返回的目录版本须与主进程一致,否则模型查到的字段和校验依据不同 # 物料服务返回的目录版本须与主进程一致,否则模型查到的字段和校验依据不同
MATERIAL_SERVER = "materials" MATERIAL_SERVER = "materials"
LOCAL_HOSTS = {"localhost", "127.0.0.1"} LOCAL_HOSTS = {"localhost", "127.0.0.1"}
# 配置中引用环境变量的写法
ENV_REFERENCE = re.compile(r"\$\{(\w+)\}")
class MCPServiceError(RuntimeError): class MCPServiceError(RuntimeError):
"""MCP 配置错误、服务不可用,或物料目录版本不一致""" """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]: def load_mcp_config(path: Path = CONFIG_PATH) -> dict[str, dict]:
"""读取服务列表并展开环境变量;文件缺失或格式不对时报错""" """读取服务列表并展开环境变量;文件缺失或格式不对时报错"""
if not path.exists(): if not path.exists():
raise MCPServiceError(f"未找到 MCP 服务配置: {path}") raise MCPServiceError(f"未找到 MCP 服务配置: {path}")
try: 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: except json.JSONDecodeError as error:
raise MCPServiceError(f"MCP 服务配置不是合法 JSON: {error}") from error raise MCPServiceError(f"MCP 服务配置不是合法 JSON: {error}") from error
if not isinstance(config, dict) or not all( if not isinstance(config, dict) or not all(
+6
View File
@@ -17,6 +17,7 @@ from app.core.logging import setup_logging
@asynccontextmanager @asynccontextmanager
async def lifespan(application: FastAPI): async def lifespan(application: FastAPI):
setup_logging() setup_logging()
from app.agent.screen_agent import get_screen_agent
from app.clients.decision import close_decision_engine from app.clients.decision import close_decision_engine
from app.clients.mcp import start_mcp_servers, stop_mcp_servers from app.clients.mcp import start_mcp_servers, stop_mcp_servers
from app.clients.user_center import close_user_center from app.clients.user_center import close_user_center
@@ -35,6 +36,11 @@ async def lifespan(application: FastAPI):
await warm_material_cache(persistence.db) await warm_material_cache(persistence.db)
# 拉起 mcp_servers.json 中由应用管理的服务;单个服务失败只记录,用到它的请求执行时报错 # 拉起 mcp_servers.json 中由应用管理的服务;单个服务失败只记录,用到它的请求执行时报错
mcp_processes = await start_mcp_servers() 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( application.state.agent_service = AgentService(
build_graph(persistence.checkpointer), persistence.store build_graph(persistence.checkpointer), persistence.store
) )
-20
View File
@@ -55,26 +55,6 @@ def flush_tracing() -> None:
logger.opt(exception=exc).warning("Langfuse flush 失败") 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): async def astream_screen(graph, payload, *, thread_id, session_id=None, user_id=None):
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪""" """同一轮流保持同一个追踪上下文,关闭流时结束追踪"""
from contextlib import aclosing from contextlib import aclosing
-4
View File
@@ -81,7 +81,3 @@ def build_graph(checkpointer=None):
if checkpointer is None: if checkpointer is None:
checkpointer = InMemorySaver(serde=build_checkpoint_serde()) checkpointer = InMemorySaver(serde=build_checkpoint_serde())
return workflow.compile(checkpointer=checkpointer) return workflow.compile(checkpointer=checkpointer)
# 模块加载时创建共享图实例,复用该实例的调用也共用其检查点存储
query_app = build_graph()
-2
View File
@@ -62,8 +62,6 @@ class ScreenAgentNode(BaseNode):
] ]
}, },
context=ScreenAgentContext(screen=context.screen, catalog=catalog), 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: if result.get("structured_response") is None:
last = result["messages"][-1] last = result["messages"][-1]
+1 -1
View File
@@ -105,7 +105,7 @@ def list_components(catalog: ComponentCatalog) -> dict:
def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict: def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict:
"""指定类型的物料默认配置,同一类型的多个预设全部返回""" """指定类型的物料默认配置"""
known = {item.type for item in catalog.components} known = {item.type for item in catalog.components}
if unknown := [item for item in component_types if item not in known]: if unknown := [item for item in component_types if item not in known]:
raise ValueError( raise ValueError(
+2 -6
View File
@@ -5,7 +5,6 @@ from typing import Literal
from pydantic import Field from pydantic import Field
from app.schemas.actions.base import ActionModel from app.schemas.actions.base import ActionModel
from app.schemas.base import NonBlankString
from app.schemas.screen import ScreenNode from app.schemas.screen import ScreenNode
@@ -13,13 +12,10 @@ class AddAction(ActionModel):
"""按物料新增组件""" """按物料新增组件"""
type: Literal["add"] = "add" type: Literal["add"] = "add"
component_key: NonBlankString = Field(
description="物料目录编号,即 list_components 返回的 key"
)
node: ScreenNode = Field( node: ScreenNode = Field(
description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;"
"后端按物料默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。"
"id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 必须与物料一致" "id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 用 list_components 返回的 type"
) )
sample_data: bool = Field( sample_data: bool = Field(
default=False, default=False,
View File
-16
View File
@@ -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 帮助
+2 -8
View File
@@ -46,10 +46,7 @@ def _require_catalog() -> ComponentCatalog:
@mcp.tool() @mcp.tool()
async def list_components() -> dict[str, Any]: async def list_components() -> dict[str, Any]:
"""列出可以新增的组件:key、type、名称、用途和默认宽高 """列出可以新增的组件:key、type、名称、用途和默认宽高"""
新建大屏、新增组件或更换类型时,先从这里选类型,再用 get_component_materials 查看默认配置
"""
return material_fields.list_components(_require_catalog()) return material_fields.list_components(_require_catalog())
@@ -60,10 +57,7 @@ async def get_component_materials(
Field(description="要使用的物料类型,可一次传多个", min_length=1), Field(description="要使用的物料类型,可一次传多个", min_length=1),
], ],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""获取物料的完整默认配置(schema)和使用说明 """获取物料的完整默认配置(schema)和使用说明"""
新增组件前先查看所选类型的默认配置,只覆盖需要修改的字段,不从零编写 props
"""
return material_fields.get_materials(_require_catalog(), component_types) return material_fields.get_materials(_require_catalog(), component_types)
+1 -1
View File
@@ -11,7 +11,7 @@ description: 把已有组件换成另一种组件类型,例如"把折线图换
2. 用 list_components 选出用户要的新类型,用 get_component_materials 查看它的默认配置 2. 用 list_components 选出用户要的新类型,用 get_component_materials 查看它的默认配置
3. 在同一批动作里先 delete 原组件,再 add 新组件: 3. 在同一批动作里先 delete 原组件,再 add 新组件:
- node.id 沿用原组件 ID,layout 沿用原组件的位置和尺寸(用户另有要求除外) - node.id 沿用原组件 ID,layout 沿用原组件的位置和尺寸(用户另有要求除外)
- component_key 选新类型对应的物料 - node.type 填新类型
4. 用 apply_actions 提交 4. 用 apply_actions 提交
## 数据迁移 ## 数据迁移
+1 -5
View File
@@ -8,7 +8,7 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、
## 选组件 ## 选组件
1. 用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型 1. 用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型
2. 用 get_component_materials 查看所选类型的默认配置(可一次查多个类型),新组件以默认配置为基础,只写需要改的字段,例如标题 2. 用 get_component_materials 查看所选类型的默认配置
3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题 3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题
## 布局 ## 布局
@@ -22,7 +22,3 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、
- 重新布局:用 move 和 resize 调整已有组件,不删除、不重建 - 重新布局:用 move 和 resize 调整已有组件,不删除、不重建
- 统一风格:先用 get_nodes 读取组件配置,用 search_fields 找到颜色等字段,再逐个修改;画布背景用 update_canvas - 统一风格:先用 get_nodes 读取组件配置,用 search_fields 找到颜色等字段,再逐个修改;画布背景用 update_canvas
## 提交
本次的所有新增和调整放在同一批,用 apply_actions 提交
-4
View File
@@ -23,7 +23,3 @@ description: 修改大屏上已有的组件或画布:改标题、文字、颜
- 相对变化("放大一倍""往右移 100")依据大屏摘要中的当前位置和尺寸换算 - 相对变化("放大一倍""往右移 100")依据大屏摘要中的当前位置和尺寸换算
- "放到正中间"等按画布尺寸计算目标位置 - "放到正中间"等按画布尺寸计算目标位置
## 提交
本次的所有修改放在同一批,用 apply_actions 提交