feat:对接中心平台,完善接口鉴权
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from app.actions.base import BaseActionHandler
|
from app.actions.base import BaseActionHandler
|
||||||
|
from app.resources.material_fields import editable_fields, known_prop_keys
|
||||||
from app.schemas.actions import UpdateAction
|
from app.schemas.actions import UpdateAction
|
||||||
from app.schemas.screen import Screen
|
from app.schemas.screen import Screen
|
||||||
|
|
||||||
@@ -16,6 +17,19 @@ class UpdateHandler(BaseActionHandler[UpdateAction]):
|
|||||||
if action.section == "style" and target.style is None:
|
if action.section == "style" and target.style is None:
|
||||||
target.style = {}
|
target.style = {}
|
||||||
|
|
||||||
|
# props 第一层不能新造字段:渲染器不会读取约定外的字段,新造只会“成功”却没有效果
|
||||||
|
if action.section == "props":
|
||||||
|
known = known_prop_keys(target)
|
||||||
|
if known is not None and action.path[0] not in known:
|
||||||
|
labels = "、".join(
|
||||||
|
f"{field['label']}={field['section']}.{field['path']}"
|
||||||
|
for field in editable_fields(target.type)
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"组件 {target.type} 的 props 没有字段 {action.path[0]!r},"
|
||||||
|
f"不能新造组件级属性;可编辑字段:{labels or '无'}"
|
||||||
|
)
|
||||||
|
|
||||||
# 获取动作指定的属性区域,作为路径遍历的起点
|
# 获取动作指定的属性区域,作为路径遍历的起点
|
||||||
current = getattr(target, action.section)
|
current = getattr(target, action.section)
|
||||||
# 逐段访问路径,只有到达末级时才写入新值
|
# 逐段访问路径,只有到达末级时才写入新值
|
||||||
|
|||||||
+15
-5
@@ -1,24 +1,34 @@
|
|||||||
"""大屏 Agent 启动与澄清恢复接口"""
|
"""大屏 Agent 启动与澄清恢复接口"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from app.common.sse import AgentEventResponse
|
from app.common.sse import AgentEventResponse
|
||||||
|
from app.core.auth import get_current_user
|
||||||
from app.schemas.agent import ResumeRequest
|
from app.schemas.agent import ResumeRequest
|
||||||
from app.schemas.request import DesignRequest
|
from app.schemas.request import DesignRequest
|
||||||
|
from app.schemas.user import CurrentUser
|
||||||
|
|
||||||
router = APIRouter(prefix="/agent", tags=["Agent"])
|
router = APIRouter(prefix="/agent", tags=["Agent"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run", response_class=AgentEventResponse, status_code=200)
|
@router.post("/run", response_class=AgentEventResponse, status_code=200)
|
||||||
async def stream_agent(body: DesignRequest, request: Request):
|
async def stream_agent(
|
||||||
|
body: DesignRequest,
|
||||||
|
request: Request,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
service = request.app.state.agent_service
|
service = request.app.state.agent_service
|
||||||
# 在发送流响应头之前完成会话校验并占用任务
|
# 在发送流响应头之前完成会话校验并占用任务
|
||||||
execution = await service.prepare_run(body)
|
execution = await service.prepare_run(body, user.id)
|
||||||
return AgentEventResponse(service, execution, request)
|
return AgentEventResponse(service, execution, request)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume", response_class=AgentEventResponse, status_code=200)
|
@router.post("/resume", response_class=AgentEventResponse, status_code=200)
|
||||||
async def stream_resume_agent(body: ResumeRequest, request: Request):
|
async def stream_resume_agent(
|
||||||
|
body: ResumeRequest,
|
||||||
|
request: Request,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
service = request.app.state.agent_service
|
service = request.app.state.agent_service
|
||||||
execution = await service.prepare_resume(body)
|
execution = await service.prepare_resume(body, user.id)
|
||||||
return AgentEventResponse(service, execution, request)
|
return AgentEventResponse(service, execution, request)
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
"""会话创建与历史查询接口"""
|
"""会话创建与历史查询接口"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from app.common.response import ApiResponse, success_response
|
from app.common.response import ApiResponse, success_response
|
||||||
|
from app.core.auth import get_current_user
|
||||||
from app.core.exceptions import BizException
|
from app.core.exceptions import BizException
|
||||||
from app.schemas.conversation import ConversationCreated, ConversationDetail
|
from app.schemas.conversation import ConversationCreated, ConversationDetail
|
||||||
|
from app.schemas.user import CurrentUser
|
||||||
|
|
||||||
router = APIRouter(prefix="/conversations", tags=["Conversation"])
|
router = APIRouter(prefix="/conversations", tags=["Conversation"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_conversation(
|
async def create_conversation(
|
||||||
request: Request,
|
request: Request, user: CurrentUser = Depends(get_current_user)
|
||||||
) -> ApiResponse[ConversationCreated]:
|
) -> ApiResponse[ConversationCreated]:
|
||||||
store = request.app.state.conversation_store
|
store = request.app.state.conversation_store
|
||||||
return success_response(ConversationCreated(conversation_id=await store.create()))
|
return success_response(
|
||||||
|
ConversationCreated(conversation_id=await store.create(user.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{conversation_id}")
|
@router.get("/{conversation_id}")
|
||||||
async def get_conversation(
|
async def get_conversation(
|
||||||
conversation_id: str, request: Request
|
conversation_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> ApiResponse[ConversationDetail]:
|
) -> ApiResponse[ConversationDetail]:
|
||||||
store = request.app.state.conversation_store
|
store = request.app.state.conversation_store
|
||||||
if not await store.exists(conversation_id):
|
# 他人的会话与不存在同样返回 404
|
||||||
|
if not await store.exists(conversation_id, user.id):
|
||||||
raise BizException(404, "会话不存在或已过期")
|
raise BizException(404, "会话不存在或已过期")
|
||||||
turns = await store.load_turns(conversation_id, None)
|
turns = await store.load_turns(conversation_id, None)
|
||||||
return success_response(
|
return success_response(
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""用户中心客户端:用请求携带的 token 换取当前用户,结果短时缓存"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.config import Settings, settings
|
||||||
|
from app.core.exceptions import BizException
|
||||||
|
from app.schemas.user import CurrentUser
|
||||||
|
|
||||||
|
# 用户中心 401 时提示调用方用 Bearer token 重新认证
|
||||||
|
BEARER_CHALLENGE = {"WWW-Authenticate": "Bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
class UserCenterClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Settings | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
):
|
||||||
|
self._config = config if config is not None else settings
|
||||||
|
self._transport = transport
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
# token 摘要 -> (过期时刻, 用户);不在内存中保存明文 token
|
||||||
|
self._cache: dict[str, tuple[float, CurrentUser]] = {}
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._config.user_center_url,
|
||||||
|
timeout=self._config.user_center_timeout_seconds,
|
||||||
|
transport=self._transport,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _cached(self, key: str) -> CurrentUser | None:
|
||||||
|
entry = self._cache.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[0] <= time.monotonic():
|
||||||
|
del self._cache[key]
|
||||||
|
return None
|
||||||
|
return entry[1]
|
||||||
|
|
||||||
|
def _remember(self, key: str, user: CurrentUser) -> None:
|
||||||
|
ttl = self._config.auth_cache_ttl_seconds
|
||||||
|
if not ttl:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
# 写入时顺带清掉过期项,缓存规模不超过 TTL 内的活跃 token 数
|
||||||
|
for stale in [k for k, (expires, _) in self._cache.items() if expires <= now]:
|
||||||
|
del self._cache[stale]
|
||||||
|
self._cache[key] = (now + ttl, user)
|
||||||
|
|
||||||
|
async def fetch_current_user(self, token: str) -> CurrentUser:
|
||||||
|
key = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
user = self._cached(key)
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._get_client().get(
|
||||||
|
"/api/users/me", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as error:
|
||||||
|
logger.opt(exception=error).warning("用户中心请求失败")
|
||||||
|
raise BizException(503, "用户中心不可用") from error
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.warning("用户中心返回 HTTP {}", response.status_code)
|
||||||
|
raise BizException(503, "用户中心不可用")
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
body = {}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
# 用户中心与本服务的错误响应同构,直接透传状态码和提示
|
||||||
|
raise BizException(
|
||||||
|
response.status_code,
|
||||||
|
body.get("msg") or "认证失败",
|
||||||
|
BEARER_CHALLENGE if response.status_code == 401 else None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
user = CurrentUser.model_validate(body["data"])
|
||||||
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
|
logger.warning("用户中心返回的数据格式无效")
|
||||||
|
raise BizException(503, "用户中心不可用") from error
|
||||||
|
|
||||||
|
self._remember(key, user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _cached_client() -> UserCenterClient:
|
||||||
|
return UserCenterClient()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_center() -> UserCenterClient:
|
||||||
|
"""共享入口;适用于单个服务事件循环"""
|
||||||
|
return _cached_client()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_user_center() -> None:
|
||||||
|
"""在应用关闭阶段调用"""
|
||||||
|
if _cached_client.cache_info().currsize:
|
||||||
|
await _cached_client().aclose()
|
||||||
|
_cached_client.cache_clear()
|
||||||
@@ -79,7 +79,32 @@ class Settings:
|
|||||||
default_factory=lambda: int(os.getenv("MONGODB_CHECKPOINT_TTL", "604800"))
|
default_factory=lambda: int(os.getenv("MONGODB_CHECKPOINT_TTL", "604800"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 用户中心:请求携带的 token 交给它校验,换回 user_id
|
||||||
|
user_center_url: str = field(
|
||||||
|
default_factory=lambda: (
|
||||||
|
os.getenv("USER_CENTER_URL") or "http://127.0.0.1:8100"
|
||||||
|
).rstrip("/")
|
||||||
|
)
|
||||||
|
user_center_timeout_seconds: float = field(
|
||||||
|
default_factory=lambda: float(os.getenv("USER_CENTER_TIMEOUT_SECONDS", "5"))
|
||||||
|
)
|
||||||
|
# 关闭后不校验 token,所有请求视为同一个本地调试用户,仅用于开发
|
||||||
|
auth_enabled: bool = field(
|
||||||
|
default_factory=lambda: (
|
||||||
|
os.getenv("AUTH_ENABLED", "true").strip().lower()
|
||||||
|
not in ("false", "0", "no", "off")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# token 校验结果缓存时长(秒),登出或禁用后最多延迟这么久失效,0 表示不缓存
|
||||||
|
auth_cache_ttl_seconds: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("AUTH_CACHE_TTL_SECONDS", "30"))
|
||||||
|
)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
if self.user_center_timeout_seconds <= 0:
|
||||||
|
raise ValueError("USER_CENTER_TIMEOUT_SECONDS 必须是正数")
|
||||||
|
if self.auth_cache_ttl_seconds < 0:
|
||||||
|
raise ValueError("AUTH_CACHE_TTL_SECONDS 必须是非负整数")
|
||||||
if self.mongodb_checkpoint_ttl < 0:
|
if self.mongodb_checkpoint_ttl < 0:
|
||||||
raise ValueError("MONGODB_CHECKPOINT_TTL 必须是非负整数")
|
raise ValueError("MONGODB_CHECKPOINT_TTL 必须是非负整数")
|
||||||
if self.conversation_empty_ttl <= 0:
|
if self.conversation_empty_ttl <= 0:
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""接口鉴权:从 Authorization 头取出用户中心 token,换取当前用户"""
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
|
||||||
|
from app.clients.user_center import BEARER_CHALLENGE, UserCenterClient, get_user_center
|
||||||
|
from app.config import settings
|
||||||
|
from app.core.exceptions import BizException
|
||||||
|
from app.schemas.user import CurrentUser
|
||||||
|
|
||||||
|
# AUTH_ENABLED=false 时所有请求共用的本地调试用户
|
||||||
|
DEV_USER = CurrentUser(id=0, username="dev")
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_token(request: Request) -> str:
|
||||||
|
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
|
||||||
|
token = token.strip()
|
||||||
|
if scheme.lower() != "bearer" or not token:
|
||||||
|
raise BizException(401, "请先登录", BEARER_CHALLENGE)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request, client: UserCenterClient = Depends(get_user_center)
|
||||||
|
) -> CurrentUser:
|
||||||
|
"""同一请求内 FastAPI 会缓存结果,路由级和函数级重复声明只校验一次"""
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return DEV_USER
|
||||||
|
return await client.fetch_current_user(_bearer_token(request))
|
||||||
+13
-2
@@ -16,14 +16,25 @@ from app.exceptions import AppError, DecisionError
|
|||||||
class BizException(AppError):
|
class BizException(AppError):
|
||||||
"""可安全返回给调用方的业务错误"""
|
"""可安全返回给调用方的业务错误"""
|
||||||
|
|
||||||
def __init__(self, code: int, msg: str = "请求失败") -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: int,
|
||||||
|
msg: str = "请求失败",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
self.code = code
|
self.code = code
|
||||||
self.msg = msg
|
self.msg = msg
|
||||||
|
# 协议响应头,例如 401 时的 WWW-Authenticate
|
||||||
|
self.headers = headers
|
||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
async def handle_biz_exception(_request: Request, exc: BizException) -> JSONResponse:
|
async def handle_biz_exception(_request: Request, exc: BizException) -> JSONResponse:
|
||||||
return JSONResponse(status_code=exc.code, content=error_content(exc.code, exc.msg))
|
return JSONResponse(
|
||||||
|
status_code=exc.code,
|
||||||
|
content=error_content(exc.code, exc.msg),
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_validation_exception(
|
async def handle_validation_exception(
|
||||||
|
|||||||
+12
-3
@@ -3,12 +3,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from app.api.agent import router
|
from app.api.agent import router
|
||||||
from app.api.conversations import router as conversation_router
|
from app.api.conversations import router as conversation_router
|
||||||
from app.api.materials import router as material_router
|
from app.api.materials import router as material_router
|
||||||
|
from app.core.auth import get_current_user
|
||||||
from app.core.exceptions import register_exception_handlers
|
from app.core.exceptions import register_exception_handlers
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ from app.core.logging import setup_logging
|
|||||||
async def lifespan(application: FastAPI):
|
async def lifespan(application: FastAPI):
|
||||||
setup_logging()
|
setup_logging()
|
||||||
from app.clients.decision import close_decision_engine
|
from app.clients.decision import close_decision_engine
|
||||||
|
from app.clients.user_center import close_user_center
|
||||||
from app.observability.tracing import flush_tracing
|
from app.observability.tracing import flush_tracing
|
||||||
from app.processor.graph import build_graph
|
from app.processor.graph import build_graph
|
||||||
from app.services.agent import AgentService
|
from app.services.agent import AgentService
|
||||||
@@ -40,6 +42,9 @@ async def lifespan(application: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
await close_decision_engine()
|
await close_decision_engine()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await close_user_center()
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(flush_tracing)
|
await asyncio.to_thread(flush_tracing)
|
||||||
@@ -52,8 +57,12 @@ def create_app() -> FastAPI:
|
|||||||
# 统一处理请求校验、HTTP 错误和未捕获异常
|
# 统一处理请求校验、HTTP 错误和未捕获异常
|
||||||
register_exception_handlers(application)
|
register_exception_handlers(application)
|
||||||
# 统一 /api 前缀,方便前端代理和反向代理只配置一条规则
|
# 统一 /api 前缀,方便前端代理和反向代理只配置一条规则
|
||||||
application.include_router(router, prefix="/api")
|
# 会话和 agent 接口需登录,物料目录是全局公开数据
|
||||||
application.include_router(conversation_router, prefix="/api")
|
authenticated = [Depends(get_current_user)]
|
||||||
|
application.include_router(router, prefix="/api", dependencies=authenticated)
|
||||||
|
application.include_router(
|
||||||
|
conversation_router, prefix="/api", dependencies=authenticated
|
||||||
|
)
|
||||||
application.include_router(material_router, prefix="/api")
|
application.include_router(material_router, prefix="/api")
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|||||||
@@ -61,24 +61,28 @@ async def ainvoke_screen(
|
|||||||
*,
|
*,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""每轮调用记录一个根 span,返回、等待用户或异常时结束。
|
"""每轮调用记录一个根 span,返回、等待用户或异常时结束。
|
||||||
|
|
||||||
session_id 由调用方在首次与恢复时一致传入(通常为 conversation_id)。
|
session_id 由调用方在首次与恢复时一致传入(通常为 conversation_id)。
|
||||||
未传时始终使用 thread_id,不从仅首次请求才有的 payload 推断。
|
未传时始终使用 thread_id,不从仅首次请求才有的 payload 推断。
|
||||||
|
user_id 为用户中心的用户 ID,接口调用时传入,本地脚本不传。
|
||||||
"""
|
"""
|
||||||
with _screen_trace(payload, thread_id, session_id) as (config, root):
|
with _screen_trace(payload, thread_id, session_id, user_id) as (config, root):
|
||||||
result = await graph.ainvoke(payload, config=config)
|
result = await graph.ainvoke(payload, config=config)
|
||||||
_finish_trace(root, result)
|
_finish_trace(root, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def astream_screen(graph, payload, *, thread_id, session_id=None):
|
async def astream_screen(
|
||||||
|
graph, payload, *, thread_id, session_id=None, user_id=None
|
||||||
|
):
|
||||||
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪"""
|
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪"""
|
||||||
from contextlib import aclosing
|
from contextlib import aclosing
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
with _screen_trace(payload, thread_id, session_id) as (config, root):
|
with _screen_trace(payload, thread_id, session_id, user_id) as (config, root):
|
||||||
async with aclosing(
|
async with aclosing(
|
||||||
graph.astream(payload, config=config, stream_mode=["custom", "values"])
|
graph.astream(payload, config=config, stream_mode=["custom", "values"])
|
||||||
) as events:
|
) as events:
|
||||||
@@ -98,7 +102,7 @@ def _finish_trace(root, result):
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _screen_trace(payload, thread_id, session_id):
|
def _screen_trace(payload, thread_id, session_id, user_id=None):
|
||||||
config = {"configurable": {"thread_id": thread_id}}
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
client = _ensure_client() if tracing_enabled() else None
|
client = _ensure_client() if tracing_enabled() else None
|
||||||
if client is None:
|
if client is None:
|
||||||
@@ -128,6 +132,8 @@ def _screen_trace(payload, thread_id, session_id):
|
|||||||
propagate_attributes(
|
propagate_attributes(
|
||||||
trace_name=TRACE_NAME,
|
trace_name=TRACE_NAME,
|
||||||
session_id=session_id or thread_id,
|
session_id=session_id or thread_id,
|
||||||
|
# 用户中心的 user_id,面板 Users 页按用户聚合;脚本调用没有用户时不设
|
||||||
|
user_id=str(user_id) if user_id is not None else None,
|
||||||
tags=[FEATURE_TAG],
|
tags=[FEATURE_TAG],
|
||||||
metadata={
|
metadata={
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.clients.llm import get_llm
|
|||||||
from app.processor.base import BaseNode
|
from app.processor.base import BaseNode
|
||||||
from app.processor.state import QueryState
|
from app.processor.state import QueryState
|
||||||
from app.prompt.action import build_action_prompt
|
from app.prompt.action import build_action_prompt
|
||||||
|
from app.resources.material_fields import editable_fields
|
||||||
from app.schemas.action_validation import ActionValidationError, schema_errors
|
from app.schemas.action_validation import ActionValidationError, schema_errors
|
||||||
from app.schemas.actions import ActionBatch, AddAction
|
from app.schemas.actions import ActionBatch, AddAction
|
||||||
from app.schemas.operations import is_direct_operation
|
from app.schemas.operations import is_direct_operation
|
||||||
@@ -58,6 +59,28 @@ def action_block_reason(state: QueryState) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_fields(state: QueryState) -> dict[str, list[dict]]:
|
||||||
|
"""按组件 ID 汇总可能被属性更新的已有组件的可编辑字段,没有字段的组件省略"""
|
||||||
|
plan = state.get("screen_plan")
|
||||||
|
if plan is None:
|
||||||
|
target_ids = [
|
||||||
|
target
|
||||||
|
for change in state["requirement"].changes
|
||||||
|
if change.target_scope == "nodes"
|
||||||
|
for target in change.target_ids
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
target_ids = [
|
||||||
|
item.target_id for item in plan.components if item.disposition == "modify"
|
||||||
|
]
|
||||||
|
nodes = {node.id: node for node in state["input_context"].screen.nodes}
|
||||||
|
result = {}
|
||||||
|
for target in dict.fromkeys(target_ids):
|
||||||
|
if target in nodes and (fields := editable_fields(nodes[target].type)):
|
||||||
|
result[target] = fields
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ActionNode(BaseNode):
|
class ActionNode(BaseNode):
|
||||||
name = "action_node"
|
name = "action_node"
|
||||||
|
|
||||||
@@ -92,6 +115,10 @@ class ActionNode(BaseNode):
|
|||||||
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
|
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
|
||||||
payload["component_materials"] = plan_materials(state)
|
payload["component_materials"] = plan_materials(state)
|
||||||
|
|
||||||
|
# 提供待修改组件的属性面板字段,让模型把用户说的“标题”“颜色”等对应到真实路径
|
||||||
|
if fields := update_fields(state):
|
||||||
|
payload["editable_fields"] = fields
|
||||||
|
|
||||||
# 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考
|
# 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考
|
||||||
if state.get("repair_plan") is not None:
|
if state.get("repair_plan") is not None:
|
||||||
payload.update(
|
payload.update(
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ properties 只包含需要修改的画布顶层字段,宽高为最终绝对值
|
|||||||
依据快照选择 props / style 下的字段路径,不猜测不存在的嵌套结构
|
依据快照选择 props / style 下的字段路径,不猜测不存在的嵌套结构
|
||||||
path 中字符串是对象键,整数是数组下标;中间路径必须存在,数组下标不能越界
|
path 中字符串是对象键,整数是数组下标;中间路径必须存在,数组下标不能越界
|
||||||
只替换指定字段,不做深度合并;null 表示赋值,不表示删除
|
只替换指定字段,不做深度合并;null 表示赋值,不表示删除
|
||||||
|
提供 editable_fields 时,它是该组件属性面板的字段:用户说的名称(如名字、标题、颜色)优先对应 label 相符的字段,使用其 section 和 path
|
||||||
|
props 第一层只能使用组件已有或物料默认配置中的字段,不新造组件级属性;更深层的配置可以按需新增
|
||||||
不通过修改属性改变组件类型(如把 series 的 type 改成其他图表类型),组件类型由节点 type 和物料决定
|
不通过修改属性改变组件类型(如把 series 的 type 改成其他图表类型),组件类型由节点 type 和物料决定
|
||||||
style 为 null 时可用单层路径添加属性,保留未要求修改的配置
|
style 为 null 时可用单层路径添加属性,保留未要求修改的配置
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""读取前端组件目录:启动时载入进程内存缓存,未载入时回退读取本地 JSON 文件"""
|
"""查询前端组件目录:目录只来自启动时从 MongoDB 载入的进程内存缓存,没有文件回退"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -8,8 +8,6 @@ from app.schemas.component_resource import (
|
|||||||
ComponentResult,
|
ComponentResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_CATALOG_PATH = Path(__file__).resolve().parent / "catalog" / "components.json"
|
|
||||||
|
|
||||||
# 进程内共享的组件目录,由启动流程载入;agent 与前端接口读取同一份
|
# 进程内共享的组件目录,由启动流程载入;agent 与前端接口读取同一份
|
||||||
_cached_catalog: ComponentCatalog | None = None
|
_cached_catalog: ComponentCatalog | None = None
|
||||||
|
|
||||||
@@ -23,36 +21,24 @@ def get_catalog_cache() -> ComponentCatalog | None:
|
|||||||
return _cached_catalog
|
return _cached_catalog
|
||||||
|
|
||||||
|
|
||||||
def load_catalog_file(path: str | Path | None = None) -> ComponentCatalog | None:
|
def load_catalog_file(path: str | Path) -> ComponentCatalog:
|
||||||
|
"""读取导出的目录文件,只供导入脚本使用,运行时不读取文件"""
|
||||||
# 如果传入的是相对路径,以项目根目录为基准,避免受启动时工作目录影响
|
# 如果传入的是相对路径,以项目根目录为基准,避免受启动时工作目录影响
|
||||||
target = Path(path) if path is not None else DEFAULT_CATALOG_PATH
|
target = Path(path)
|
||||||
if not target.is_absolute():
|
if not target.is_absolute():
|
||||||
target = Path(__file__).resolve().parents[2] / target
|
target = Path(__file__).resolve().parents[2] / target
|
||||||
try:
|
# utf-8-sig 同时兼容带 BOM 和不带 BOM 的 UTF-8 文件;损坏或不合法直接报错
|
||||||
# utf-8-sig 同时兼容带 BOM 和不带 BOM 的 UTF-8 文件
|
return ComponentCatalog.model_validate_json(target.read_text(encoding="utf-8-sig"))
|
||||||
content = target.read_text(encoding="utf-8-sig")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
# 文件损坏或定义不合法直接报错,不能伪装成查无结果
|
|
||||||
return ComponentCatalog.model_validate_json(content)
|
|
||||||
|
|
||||||
|
|
||||||
class ComponentProvider:
|
class ComponentProvider:
|
||||||
def __init__(self, catalog_path: str | Path | None = None):
|
|
||||||
# 指定路径时始终读取该文件;未指定时优先使用启动缓存,缓存为空再读默认文件
|
|
||||||
self.catalog_path = catalog_path
|
|
||||||
|
|
||||||
def query(self, query: ComponentQuery) -> ComponentResult:
|
def query(self, query: ComponentQuery) -> ComponentResult:
|
||||||
catalog = (
|
catalog = _cached_catalog
|
||||||
_cached_catalog
|
|
||||||
if self.catalog_path is None and _cached_catalog is not None
|
|
||||||
else load_catalog_file(self.catalog_path)
|
|
||||||
)
|
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
# 如果目录文件不存在,返回未支持状态和原因,由调用方决定失败处理方式
|
# 启动时未载入目录,返回未支持状态和原因,由调用方决定失败处理方式
|
||||||
return ComponentResult(
|
return ComponentResult(
|
||||||
status="unsupported",
|
status="unsupported",
|
||||||
message="尚未配置前端组件目录,请先导出真实组件定义",
|
message="物料目录未载入,请确认已配置 MongoDB 并导入物料",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果提供关键词,去除首尾空白并统一大小写,未提供时不启用关键词筛选
|
# 如果提供关键词,去除首尾空白并统一大小写,未提供时不启用关键词筛选
|
||||||
@@ -92,9 +78,3 @@ class ComponentProvider:
|
|||||||
catalog_version=catalog.version,
|
catalog_version=catalog.version,
|
||||||
message="已找到组件定义" if matches else "已查询组件目录,未找到匹配的组件",
|
message="已找到组件定义" if matches else "已查询组件目录,未找到匹配的组件",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
provider = ComponentProvider()
|
|
||||||
result = provider.query(ComponentQuery())
|
|
||||||
print(result.model_dump_json(indent=2))
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""从物料目录读取组件的可编辑字段,供动作生成理解字段含义,并供校验约束属性结构"""
|
||||||
|
|
||||||
|
from app.resources.component import ComponentProvider
|
||||||
|
from app.schemas.component_resource import ComponentDefinition, ComponentQuery
|
||||||
|
from app.schemas.screen import ScreenNode
|
||||||
|
|
||||||
|
# 属性面板的 key 以区域开头,只有这两个区域可以被 update 修改
|
||||||
|
EDITABLE_SECTIONS = ("props", "style")
|
||||||
|
|
||||||
|
|
||||||
|
def _materials(node_type: str) -> list[ComponentDefinition]:
|
||||||
|
# 多个物料预设可以共用同一前端 type,按 type 取全部预设
|
||||||
|
result = ComponentProvider().query(ComponentQuery(type=node_type))
|
||||||
|
return result.components if result.status == "resolved" else []
|
||||||
|
|
||||||
|
|
||||||
|
def _split_key(key: str) -> tuple[str, list[str | int]] | None:
|
||||||
|
# 前端点路径中的纯数字段是数组下标,转换为与 UpdateAction.path 一致的整数
|
||||||
|
section, *path = key.split(".")
|
||||||
|
if section not in EDITABLE_SECTIONS or not path:
|
||||||
|
return None
|
||||||
|
return section, [int(part) if part.isdigit() else part for part in path]
|
||||||
|
|
||||||
|
|
||||||
|
def editable_fields(node_type: str) -> list[dict]:
|
||||||
|
"""返回组件属性面板中的字段:用户可见名称、区域、路径及可选值,按出现顺序去重"""
|
||||||
|
fields: dict[str, dict] = {}
|
||||||
|
for material in _materials(node_type):
|
||||||
|
for setter in material.setters:
|
||||||
|
parsed = _split_key(setter.key)
|
||||||
|
if parsed is None or setter.key in fields:
|
||||||
|
continue
|
||||||
|
section, path = parsed
|
||||||
|
field = {"label": setter.label, "section": section, "path": path}
|
||||||
|
if options := setter.props.get("options"):
|
||||||
|
field["options"] = options
|
||||||
|
fields[setter.key] = field
|
||||||
|
return list(fields.values())
|
||||||
|
|
||||||
|
|
||||||
|
def known_prop_keys(node: ScreenNode) -> set[str] | None:
|
||||||
|
"""props 第一层是组件与渲染器的约定,只能是节点已有或物料默认配置中的字段;
|
||||||
|
目录中找不到该类型时约定未知,返回 None 表示不做限制"""
|
||||||
|
materials = _materials(node.type)
|
||||||
|
if not materials:
|
||||||
|
return None
|
||||||
|
keys = set(node.props)
|
||||||
|
for material in materials:
|
||||||
|
keys.update(material.schema_.props)
|
||||||
|
return keys
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""用户中心返回的当前用户,只保留本服务用到的字段"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
+15
-6
@@ -31,12 +31,12 @@ class AgentService:
|
|||||||
self._active: set[str] = set()
|
self._active: set[str] = set()
|
||||||
self._conversation_of: dict[str, str] = {}
|
self._conversation_of: dict[str, str] = {}
|
||||||
|
|
||||||
async def prepare_run(self, request: DesignRequest):
|
async def prepare_run(self, request: DesignRequest, user_id: int):
|
||||||
# 会话 ID 必须由后端签发,不再为缺失的 ID 兜底生成
|
# 会话 ID 必须由后端签发,不再为缺失的 ID 兜底生成
|
||||||
conversation_id = request.context.conversation_id
|
conversation_id = request.context.conversation_id
|
||||||
if conversation_id is None:
|
if conversation_id is None:
|
||||||
raise BizException(422, "请先创建会话并传入 conversation_id")
|
raise BizException(422, "请先创建会话并传入 conversation_id")
|
||||||
if not await self.store.exists(conversation_id):
|
if not await self.store.exists(conversation_id, user_id):
|
||||||
raise BizException(404, "会话不存在或已过期")
|
raise BizException(404, "会话不存在或已过期")
|
||||||
|
|
||||||
# 新请求始终独立建任务,不用 conversation_id 充当检查点 ID
|
# 新请求始终独立建任务,不用 conversation_id 充当检查点 ID
|
||||||
@@ -72,9 +72,10 @@ class AgentService:
|
|||||||
"message_id": str(uuid4()),
|
"message_id": str(uuid4()),
|
||||||
},
|
},
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def prepare_resume(self, request: ResumeRequest):
|
async def prepare_resume(self, request: ResumeRequest, user_id: int):
|
||||||
# 在发送流响应头之前完成校验并占用任务
|
# 在发送流响应头之前完成校验并占用任务
|
||||||
thread_id = str(request.thread_id)
|
thread_id = str(request.thread_id)
|
||||||
# 检查和占用之间没有 await,同一事件循环内不会重复进入
|
# 检查和占用之间没有 await,同一事件循环内不会重复进入
|
||||||
@@ -83,7 +84,10 @@ class AgentService:
|
|||||||
snapshot = await self.graph.aget_state(
|
snapshot = await self.graph.aget_state(
|
||||||
{"configurable": {"thread_id": thread_id}}
|
{"configurable": {"thread_id": thread_id}}
|
||||||
)
|
)
|
||||||
if not snapshot.values:
|
# 不属于当前用户的任务与不存在同样处理,先于其余校验,不泄露任务状态
|
||||||
|
if not snapshot.values or not await self.store.exists(
|
||||||
|
snapshot.values["conversation_id"], user_id
|
||||||
|
):
|
||||||
raise BizException(404, "任务不存在或已过期")
|
raise BizException(404, "任务不存在或已过期")
|
||||||
if not any(task.interrupts for task in snapshot.tasks):
|
if not any(task.interrupts for task in snapshot.tasks):
|
||||||
raise BizException(409, "任务当前未等待用户补充")
|
raise BizException(409, "任务当前未等待用户补充")
|
||||||
@@ -119,6 +123,7 @@ class AgentService:
|
|||||||
update={"message_id": str(uuid4())},
|
update={"message_id": str(uuid4())},
|
||||||
),
|
),
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self.release(thread_id)
|
self.release(thread_id)
|
||||||
@@ -196,13 +201,17 @@ class AgentService:
|
|||||||
failure_reason=state.get("failure_reason"),
|
failure_reason=state.get("failure_reason"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream(self, thread_id, payload, session_id):
|
async def stream(self, thread_id, payload, session_id, user_id=None):
|
||||||
"""消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端"""
|
"""消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端"""
|
||||||
state = None
|
state = None
|
||||||
with logger.contextualize(thread_id=thread_id):
|
with logger.contextualize(thread_id=thread_id):
|
||||||
async with aclosing(
|
async with aclosing(
|
||||||
astream_screen(
|
astream_screen(
|
||||||
self.graph, payload, thread_id=thread_id, session_id=session_id
|
self.graph,
|
||||||
|
payload,
|
||||||
|
thread_id=thread_id,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
) as events:
|
) as events:
|
||||||
async for mode, data in events:
|
async for mode, data in events:
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from app.schemas.conversation import Turn
|
|||||||
class ConversationStore(Protocol):
|
class ConversationStore(Protocol):
|
||||||
"""内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储"""
|
"""内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储"""
|
||||||
|
|
||||||
async def create(self) -> str: ...
|
async def create(self, user_id: int) -> str: ...
|
||||||
|
|
||||||
async def exists(self, conversation_id: str) -> bool: ...
|
async def exists(self, conversation_id: str, user_id: int) -> bool:
|
||||||
|
"""会话已签发、未过期且属于该用户;不属于时与不存在同样返回 False"""
|
||||||
|
...
|
||||||
|
|
||||||
async def load_turns(
|
async def load_turns(
|
||||||
self, conversation_id: str, limit: int | None
|
self, conversation_id: str, limit: int | None
|
||||||
@@ -32,6 +34,7 @@ class InMemoryConversationStore:
|
|||||||
self._empty_ttl = timedelta(seconds=ttl)
|
self._empty_ttl = timedelta(seconds=ttl)
|
||||||
# 会话 ID -> 创建时间;轮次按 thread_id 去重并保持首次写入顺序
|
# 会话 ID -> 创建时间;轮次按 thread_id 去重并保持首次写入顺序
|
||||||
self._created: dict[str, datetime] = {}
|
self._created: dict[str, datetime] = {}
|
||||||
|
self._owner: dict[str, int] = {}
|
||||||
self._turns: dict[str, dict[str, Turn]] = {}
|
self._turns: dict[str, dict[str, Turn]] = {}
|
||||||
|
|
||||||
def _purge_expired(self) -> None:
|
def _purge_expired(self) -> None:
|
||||||
@@ -43,17 +46,22 @@ class InMemoryConversationStore:
|
|||||||
if created < deadline and not self._turns.get(key)
|
if created < deadline and not self._turns.get(key)
|
||||||
]:
|
]:
|
||||||
del self._created[conversation_id]
|
del self._created[conversation_id]
|
||||||
|
self._owner.pop(conversation_id, None)
|
||||||
self._turns.pop(conversation_id, None)
|
self._turns.pop(conversation_id, None)
|
||||||
|
|
||||||
async def create(self) -> str:
|
async def create(self, user_id: int) -> str:
|
||||||
self._purge_expired()
|
self._purge_expired()
|
||||||
conversation_id = str(uuid4())
|
conversation_id = str(uuid4())
|
||||||
self._created[conversation_id] = _now()
|
self._created[conversation_id] = _now()
|
||||||
|
self._owner[conversation_id] = user_id
|
||||||
return conversation_id
|
return conversation_id
|
||||||
|
|
||||||
async def exists(self, conversation_id: str) -> bool:
|
async def exists(self, conversation_id: str, user_id: int) -> bool:
|
||||||
self._purge_expired()
|
self._purge_expired()
|
||||||
return conversation_id in self._created
|
return (
|
||||||
|
conversation_id in self._created
|
||||||
|
and self._owner.get(conversation_id) == user_id
|
||||||
|
)
|
||||||
|
|
||||||
async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]:
|
async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]:
|
||||||
turns = list(self._turns.get(conversation_id, {}).values())
|
turns = list(self._turns.get(conversation_id, {}).values())
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class MongoConversationStore:
|
|||||||
)
|
)
|
||||||
# 空会话清理按创建时间扫描
|
# 空会话清理按创建时间扫描
|
||||||
await self._conversations.create_index("created_at")
|
await self._conversations.create_index("created_at")
|
||||||
|
# 按用户查询会话
|
||||||
|
await self._conversations.create_index("user_id")
|
||||||
|
|
||||||
async def _purge_expired(self) -> None:
|
async def _purge_expired(self) -> None:
|
||||||
# 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响
|
# 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响
|
||||||
@@ -63,17 +65,20 @@ class MongoConversationStore:
|
|||||||
if empty:
|
if empty:
|
||||||
await self._conversations.delete_many({"_id": {"$in": empty}})
|
await self._conversations.delete_many({"_id": {"$in": empty}})
|
||||||
|
|
||||||
async def create(self) -> str:
|
async def create(self, user_id: int) -> str:
|
||||||
await self._purge_expired()
|
await self._purge_expired()
|
||||||
conversation_id = str(uuid4())
|
conversation_id = str(uuid4())
|
||||||
# user_id 为后续长期记忆预留,当前恒为空
|
# user_id 来自用户中心,会话只对创建者可见
|
||||||
await self._conversations.insert_one(
|
await self._conversations.insert_one(
|
||||||
{"_id": conversation_id, "created_at": _now(), "user_id": None}
|
{"_id": conversation_id, "created_at": _now(), "user_id": user_id}
|
||||||
)
|
)
|
||||||
return conversation_id
|
return conversation_id
|
||||||
|
|
||||||
async def exists(self, conversation_id: str) -> bool:
|
async def exists(self, conversation_id: str, user_id: int) -> bool:
|
||||||
document = await self._conversations.find_one({"_id": conversation_id})
|
# 不属于该用户的会话与不存在同样处理,不泄露会话是否存在
|
||||||
|
document = await self._conversations.find_one(
|
||||||
|
{"_id": conversation_id, "user_id": user_id}
|
||||||
|
)
|
||||||
if document is None:
|
if document is None:
|
||||||
return False
|
return False
|
||||||
if document["created_at"] >= _now() - self._empty_ttl:
|
if document["created_at"] >= _now() - self._empty_ttl:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pymongo.asynchronous.database import AsyncDatabase
|
from pymongo.asynchronous.database import AsyncDatabase
|
||||||
|
|
||||||
from app.resources.component import load_catalog_file, set_catalog_cache
|
from app.resources.component import set_catalog_cache
|
||||||
from app.schemas.component_resource import ComponentCatalog
|
from app.schemas.component_resource import ComponentCatalog
|
||||||
|
|
||||||
# 元数据记录的 _id,组件记录的 _id 为组件 key
|
# 元数据记录的 _id,组件记录的 _id 为组件 key
|
||||||
@@ -49,11 +49,13 @@ class MongoMaterialRepository:
|
|||||||
|
|
||||||
|
|
||||||
async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | None:
|
async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | None:
|
||||||
"""启动时把物料目录载入进程缓存;Mongo 未启用或尚未导入时回退读本地 JSON"""
|
"""启动时把物料目录从 MongoDB 载入进程缓存;未启用或尚未导入时目录为空,不回退读文件"""
|
||||||
catalog = await MongoMaterialRepository(db).load_all() if db is not None else None
|
catalog = await MongoMaterialRepository(db).load_all() if db is not None else None
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
catalog = load_catalog_file()
|
logger.error(
|
||||||
logger.warning("物料未从 MongoDB 加载,回退使用本地目录文件")
|
"物料目录未载入:{},新增、更换组件等需要物料的修改将无法执行",
|
||||||
|
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components))
|
logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components))
|
||||||
set_catalog_cache(catalog)
|
set_catalog_cache(catalog)
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ dependencies = [
|
|||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
"pymongo>=4.17.0",
|
"pymongo>=4.17.0",
|
||||||
"langgraph-checkpoint-mongodb>=0.5.0",
|
"langgraph-checkpoint-mongodb>=0.5.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1117,6 +1117,7 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "langchain" },
|
{ name = "langchain" },
|
||||||
{ name = "langchain-openai" },
|
{ name = "langchain-openai" },
|
||||||
{ name = "langfuse" },
|
{ name = "langfuse" },
|
||||||
@@ -1133,6 +1134,7 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "langchain", specifier = ">=1.4.2" },
|
{ name = "langchain", specifier = ">=1.4.2" },
|
||||||
{ name = "langchain-openai", specifier = ">=1.6.2" },
|
{ name = "langchain-openai", specifier = ">=1.6.2" },
|
||||||
{ name = "langfuse", specifier = "==4.15.6" },
|
{ name = "langfuse", specifier = "==4.15.6" },
|
||||||
|
|||||||
Reference in New Issue
Block a user